diff --git a/.github/workflows/pr-to-feishu.yml b/.github/workflows/pr-to-feishu.yml new file mode 100644 index 000000000..4fcc8c9e7 --- /dev/null +++ b/.github/workflows/pr-to-feishu.yml @@ -0,0 +1,17 @@ +name: Feishu Notify (Org-wide, No Relay) +on: + pull_request: + types: [opened, ready_for_review, closed] + issues: + types: [opened, closed, reopened] + +jobs: + notify: + uses: /.github/.github/workflows/feishu-notify-template.yml@main + secrets: inherit + with: + important_labels: "urgent,security,release-blocker" + important_paths_regex: "^(apps/web/|infra/|payment/|security/)" + pr_min_changed_files: "30" + title_keywords_regex: "(hotfix|rollback|security|incident)" + notify_issue_on_open_close_without_labels: "false" diff --git a/.gitignore b/.gitignore index 4b059fb78..520269612 100644 --- a/.gitignore +++ b/.gitignore @@ -36,10 +36,12 @@ release # 忽略所有.db文件 *.db -# 忽略所有.exe文件(太) +# 忽略所有.exe文件 *.exe # 忽略/public/bin目录下的非.md文件 public/bin/* !public/bin/webkit !public/bin/*.md + +sh/* \ No newline at end of file diff --git a/README.md b/README.md index 3ee6be1f7..5d0d104c4 100644 --- a/README.md +++ b/README.md @@ -16,64 +16,122 @@ AiToEarn helps creators, brands, and businesses build, distribute, and monetize Supported Channels: Douyin, Xiaohongshu (Rednote), WeChat Channels, Kuaishou, Bilibili, WeChat Official Accounts, -TikTok, YouTube, Facebook, Instagram, Threads, Twitter (X), Pinterest +TikTok, YouTube, Facebook, Instagram, Threads, Twitter (X), Pinterest, LinkedIn

Table of Contents

- +
- - 1. [Quick Start](#quick-start) - 2. [Start Web Project](#start-web-project) - 3. [Start Electron Project](#start-electron-project) - 4. [Key Features](#key-features) - 5. [MCP Service](#mcp-service) - 6. [Advanced Setup](#advanced-setup) - 7. [Contribution Guide](#contribution-guide) - 8. [Contact](#contact) - 9. [Milestones](#milestones) - 10. [FAQ](#faq) - 11. [Recommended](#recommended) + + 1. [Quick Start for Creators (Apps & Web)](#quick-start-for-creators-apps--web) + 2. [Quick Start for Developers (Docker, Recommended)](#quick-start-for-developers-docker-recommended) + 3. [Key Features](#key-features) + 4. [MCP Service](#mcp-service) + 5. [Advanced Setup](#advanced-setup) + 6. [Contribution Guide](#contribution-guide) + 7. [Contact](#contact) + 8. [Milestones](#milestones) + 9. [FAQ](#faq) + 10. [Recommended](#recommended)
-## Quick Start + + + +## Quick Start for Creators (Apps & Web) OS | Download -- | -- -Android | [![Download Android](https://img.shields.io/badge/APK-Android1.1.0-green?logo=android&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/aitoearn-1.1.0.apk) -Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.1.0-blue?logo=windows&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarnSetup-1.1.0.exe) -macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.1.0-black?logo=apple&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarn.1.1.0.dmg) +Android | [![Download Android](https://img.shields.io/badge/APK-Android1.3.2-green?logo=android&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.3.2/AiToEarn-1.3.2-internal-arm64-v8a.apk) +Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.3.2-blue?logo=windows&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.3.2/AiToEarn-Setup-1.3.2.exe) +macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.3.2-black?logo=apple&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.3.2/AiToEarn+1.3.2.dmg) iOS | **Coming soon!** Web | [Use on Web](https://aitoearn.ai/en/accounts) [Google Play Download](https://play.google.com/store/apps/details?id=com.yika.aitoearn.aitoearn_app) +## Quick Start for Developers (Docker, Recommended) + +This is the easiest way to run AiToEarn. It will start the **frontend, backend, MongoDB and Redis** with one command. +You **do NOT** need to install MongoDB or Redis on your machine manually. + +```bash +git clone https://github.com/yikart/AiToEarn.git +cd AiToEarn +cp env.example .env +docker compose up -d +```` + + +### 🌐 Access Applications + +After Docker starts successfully, you can access services at: + +| Service | URL | Description | +| ----------------------- | ---------------------------------------------- | ----------------------------------------------------------- | +| **Web Frontend** | [http://localhost:3000](http://localhost:3000) | Web user interface | +| **Main Backend API** | [http://localhost:3002](http://localhost:3002) | AiToEarn main server API | +| **Channel Service API** | [http://localhost:7001](http://localhost:7001) | AiToEarn channel service API | +| **MongoDB** | localhost:27017 | MongoDB (inside Docker, uses username/password from `.env`) | +| **Redis** | localhost:6379 | Redis (inside Docker, uses password from `.env`) | + +> ℹ️ MongoDB & Redis are both started by `docker compose`. +> You only need to configure their passwords in `.env`; no extra local installation is required. + + +### 🧩 Advanced Configuration (.env) + +Edit the `.env` file to set secure values and customize your deployment: + +```bash +# Required security configurations +MONGODB_PASSWORD=your-secure-mongodb-password +REDIS_PASSWORD=your-secure-redis-password +JWT_SECRET=your-jwt-secret-key +INTERNAL_TOKEN=your-internal-token + +# If external access is needed, set your public API/domain +NEXT_PUBLIC_API_URL=http://your-domain.com:3002/api +APP_DOMAIN=your-domain.com +``` + +> ✅ In production, please use strong, random passwords and secrets. -## Start Web Project -### 1. Start the backend service -For local setup: -Create a `local.config.js` file under the `config` directory (copy from `./aitoearn_web/server/aitoearn-user/config/dev.config.js` and adjust configs). +
+🧪 Optional: Run backend & frontend manually (dev mode) + +This mode is mainly for local development & debugging. +You can still use Docker for MongoDB/Redis or point to your own services via `.env`. + +#### 1. Start the backend services ```bash +cd project/aitoearn-monorepo pnpm install -pnpm run dev:local +npx nx serve aitoearn-channel +# in another terminal +npx nx serve aitoearn-server ``` -### 2. Start the frontend `aitoearn-web` +#### 2. Start the frontend `aitoearn-web` ```bash pnpm install pnpm run dev ``` +
+ -## Start Electron Project -```sh +
+🖥️ Optional: Start Electron desktop project + +```bash # Clone the repo git clone https://github.com/yikart/AttAiToEarn.git @@ -83,13 +141,19 @@ cd AttAiToEarn # Install dependencies npm i -# Compile sqlite (better-sqlite3 requires node-gyp, Python must be installed locally) +# Compile sqlite (better-sqlite3 requires node-gyp and local Python) npm run rebuild # Start development npm run dev ``` +The Electron project provides a desktop client for AiToEarn. + +
+ + + ## Key Features @@ -209,7 +273,8 @@ https://t.me/harryyyy2025 * 2025.08.08 — [Released web-0.1-beta](./aitoearn_web/README.md) * 2025.09.16 — [Released v1.0.18](https://github.com/yikart/AiToEarn/releases/tag/v1.0.18) * 2025.10.01 — [Released v1.0.27](https://github.com/yikart/AiToEarn/releases/tag/v1.0.27) - +* 2025.11.01 — [First Usable Version: v1.2.2](https://github.com/yikart/AiToEarn/releases/tag/v1.2.2) +* 2025.11.12 — [The first open-source, fully usable release. Released: v1.3.2](https://github.com/yikart/AiToEarn/releases/tag/v1.3.2) --- ## [FAQ](https://heovzp8pm4.feishu.cn/wiki/UksHwxdFai45SvkLf0ycblwRnTc?from=from_copylink) diff --git a/README_CN.md b/README_CN.md index cc35bd116..d187b144c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -12,7 +12,7 @@ AiToEarn 通过**AI 自动化**,帮助创作者、品牌与企业在全球主流平台上构建、分发并变现内容。 支持渠道: -抖音(Douyin)、小红书(Rednote)、视频号(WeChat Channels)、快手(Kuaishou)、哔哩哔哩(Bilibili)、微信公众号(WeChat Official Accounts)、TikTok、YouTube、Facebook、Instagram、Threads、Twitter(X)、Pinterest +抖音(Douyin)、小红书(Rednote)、视频号(WeChat Channels)、快手(Kuaishou)、哔哩哔哩(Bilibili)、微信公众号(WeChat Official Accounts)、TikTok、YouTube、Facebook、Instagram、Threads、Twitter(X)、Pinterest、LinkedIn

目录

@@ -36,53 +36,32 @@ AiToEarn 通过**AI 自动化**,帮助创作者、品牌与企业在全球主 操作系统 | 下载 -- | -- -Android | [![Download Android](https://img.shields.io/badge/APK-Android1.1.0-green?logo=android&logoColor=white)]((https://github.com/yikart/AiToEarn/releases/download/v1.1.0/aitoearn-1.1.0.apk)) -Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.1.0-blue?logo=windows&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarnSetup-1.1.0.exe) -macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.1.0-black?logo=apple&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarn.1.1.0.dmg) +Android | [![Download Android](https://img.shields.io/badge/APK-Android1.2.2-green?logo=android&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.2.2/Aitoearn-1.2.2.apk) +Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.2.2-blue?logo=windows&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.2.2/AiToEarnSetup-1.2.2.exe) +macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.2.2-black?logo=apple&logoColor=white)](https://aitoearn-download.s3.ap-southeast-1.amazonaws.com/aitoearn-download/1.2.2/AiToEarn+1.2.2.dmg) iOS | **Coming soon!** Web | [Use on Web](https://aitoearn.ai/en/accounts) -[Google Play 下载](https://play.google.com/store/apps/details?id=com.yika.aitoearn.aitoearn_app) +[Google Play Download](https://play.google.com/store/apps/details?id=com.yika.aitoearn.aitoearn_app) - -

启动 Web 项目

+

启动 Aitoearn 项目

### 1. 启动后端服务 -用于本地开发: -在 `config` 目录下创建 `local.config.js`(可从 `./aitoearn_web/server/aitoearn-user/config/dev.config.js` 复制并按需修改)。 - ```bash +cd project/aitoearn-monorepo pnpm install -pnpm run dev:local -```` +npx nx serve aitoearn-channel && npx nx serve aitoearn-server +``` ### 2. 启动前端 `aitoearn-web` ```bash +cd project/aitoearn-web pnpm install pnpm run dev ``` -

启动 Electron 项目

- -```sh -# 克隆仓库 -git clone https://github.com/yikart/AttAiToEarn.git - -# 进入目录 -cd AttAiToEarn - -# 安装依赖 -npm i - -# 编译 sqlite(better-sqlite3 依赖 node-gyp,本地需安装 Python) -npm run rebuild - -# 启动开发 -npm run dev -``` -

核心功能

🚀 **AiToEarn 是一个全链条的 AI 驱动内容增长与变现平台。** diff --git a/project/aitoearn-monorepo/.editorconfig b/project/aitoearn-monorepo/.editorconfig new file mode 100644 index 000000000..6e87a003d --- /dev/null +++ b/project/aitoearn-monorepo/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/project/aitoearn-monorepo/.github/workflows/deploy.yml b/project/aitoearn-monorepo/.github/workflows/deploy.yml new file mode 100644 index 000000000..09f5ccf91 --- /dev/null +++ b/project/aitoearn-monorepo/.github/workflows/deploy.yml @@ -0,0 +1,222 @@ +name: Deploy Service + +on: + workflow_dispatch: + inputs: + environment: + description: 选择目标环境 + required: true + type: choice + options: + - dev + - staging + - prod + version: + description: 镜像版本 (留空使用默认生成规则) + required: false + type: string + architectures: + description: 目标平台 (逗号分隔,如 linux/amd64,linux/arm64) + required: false + type: string + default: linux/amd64 + app_name: + description: 要构建的 Nx 应用名 (apps/) + required: true + type: choice + options: + - aitoearn-ai + - aitoearn-cloud-space + - aitoearn-user + - aitoearn-other + - aitoearn-payment + - aitoearn-server + +permissions: + id-token: write + contents: read + +env: + AWS_REGION: ap-southeast-1 + ECR_REGISTRY: 339388639667.dkr.ecr.ap-southeast-1.amazonaws.com + ROLE_TO_ASSUME: arn:aws:iam::339388639667:role/GithubActions + IMAGE_REPO: aitoearn + APP_NAME: ${{ github.event.inputs.app_name }} + HELM_REPO: yikart/k8s-apps + HELM_REPO_URL: https://github.com/yikart/k8s-apps.git + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + environment: ${{ github.event.inputs.environment }} + + steps: + - name: Checkout source code + uses: actions/checkout@v5 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build application + run: pnpm nx run ${{ env.APP_NAME }}:build + + - name: Prepare Docker context + run: pnpm nx run ${{ env.APP_NAME }}:docker-context + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ env.ROLE_TO_ASSUME }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Generate version + id: version + run: | + if [ -n "${{ github.event.inputs.version }}" ]; then + VERSION="${{ github.event.inputs.version }}" + else + VERSION=$(date +%Y%m%d)-$(git rev-parse --short HEAD) + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Build and push application + uses: docker/build-push-action@v5 + with: + context: ./tmp/docker-context + file: ./tmp/docker-context/Dockerfile + push: true + tags: ${{ env.ECR_REGISTRY }}/${{ env.IMAGE_REPO }}/${{ env.APP_NAME }}:${{ steps.version.outputs.version }} + platforms: ${{ github.event.inputs.architectures }} + build-args: | + APP_NAME=${{ env.APP_NAME }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: yikart + repositories: k8s-apps + + - name: Clone Helm repository + uses: actions/checkout@v5 + with: + repository: ${{ env.HELM_REPO }} + token: ${{ steps.generate-token.outputs.token }} + path: k8s-apps + + - name: Update Helm values + run: | + cd k8s-apps + ENV="${{ github.event.inputs.environment }}" + VERSION="${{ steps.version.outputs.version }}" + APP="${{ env.APP_NAME }}" + VALUES_FILE="${ENV}/values-${APP}.yaml" + + if [ -f "$VALUES_FILE" ]; then + sed -i "s|tag: .*|tag: ${VERSION}|g" "$VALUES_FILE" + echo "Updated ${APP} tag to ${VERSION}" + else + echo "Warning: Values file not found: $VALUES_FILE" + fi + + - name: Update config files + run: | + ENV="${{ github.event.inputs.environment }}" + CONFIG_FILE="apps/${{ env.APP_NAME }}/config/${ENV}.config.js" + if [ -f "$CONFIG_FILE" ]; then + CONFIG_DIR="k8s-apps/${ENV}/configmaps/${{ env.APP_NAME }}" + mkdir -p "$CONFIG_DIR" + cp "$CONFIG_FILE" "${CONFIG_DIR}/config.js" + echo "Updated ${{ env.APP_NAME }} config for ${ENV} environment" + else + echo "Warning: Config file not found: $CONFIG_FILE" + fi + + - name: Get source repository changes + id: source-changes + run: | + COMMIT_HISTORY=$(git log --oneline -10 --pretty=format:"- [%h](${{ github.server_url }}/${{ github.repository }}/commit/%H) %s") + echo 'commit_history<> $GITHUB_OUTPUT + echo "$COMMIT_HISTORY" >> $GITHUB_OUTPUT + echo 'EOF' >> $GITHUB_OUTPUT + + - name: Create Pull Request in Helm Repo + id: create-pr + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ steps.generate-token.outputs.token }} + path: ./k8s-apps + branch: deploy/${{ env.APP_NAME }}-${{ github.event.inputs.environment }}-${{ steps.version.outputs.version }} + title: 'deploy(${{ github.event.inputs.environment }}): update ${{ env.APP_NAME }} to ${{ steps.version.outputs.version }}' + body: | + ## 🚀 部署请求 + + **应用**: ${{ env.APP_NAME }} + **环境**: ${{ github.event.inputs.environment }} + **版本**: ${{ steps.version.outputs.version }} + + ### 📋 变更详情 + + - 更新镜像标签: `${{ env.APP_NAME }}:${{ steps.version.outputs.version }}` + - 目标环境: `${{ github.event.inputs.environment }}` + + ### 📝 源代码变更历史 + + 最近的提交记录: + ${{ steps.source-changes.outputs.commit_history }} + + ### 🔗 相关信息 + + - **源仓库**: ${{ github.repository }} + - **源提交**: [`${{ github.sha }}`](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}) + - **镜像地址**: `${{ env.ECR_REGISTRY }}/${{ env.IMAGE_REPO }}/${{ env.APP_NAME }}:${{ steps.version.outputs.version }}` + - **构建日志**: [GitHub Actions Run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + commit-message: | + deploy(${{ github.event.inputs.environment }}): update ${{ env.APP_NAME }} to ${{ steps.version.outputs.version }} + + Update image tag for ${{ env.APP_NAME }} in ${{ github.event.inputs.environment }} environment + Source-Commit: ${{ github.sha }} + Triggered-By: GitHub Actions + base: main + + - name: Summary + if: always() + run: | + echo "## 📦部署摘要" >> $GITHUB_STEP_SUMMARY + echo "- **应用**: ${{ env.APP_NAME }}" >> $GITHUB_STEP_SUMMARY + echo "- **环境**: ${{ github.event.inputs.environment }}" >> $GITHUB_STEP_SUMMARY + echo "- **版本**: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "- **镜像**: \`${{ env.ECR_REGISTRY }}/${{ env.IMAGE_REPO }}/${{ env.APP_NAME }}:${{ steps.version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.create-pr.outputs.pull-request-number }}" != "" ]; then + echo "- **状态**: ✅ Pull Request 已创建到 Helm 仓库" >> $GITHUB_STEP_SUMMARY + echo "- **PR链接**: ${{ steps.create-pr.outputs.pull-request-url }}" >> $GITHUB_STEP_SUMMARY + else + echo "- **状态**: ⚠️ 无变更,未创建 Pull Request" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🔗 快速链接" >> $GITHUB_STEP_SUMMARY + echo "- [Helm 仓库](${{ env.HELM_REPO_URL }})" >> $GITHUB_STEP_SUMMARY + echo "- [源代码提交](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})" >> $GITHUB_STEP_SUMMARY diff --git a/project/aitoearn-monorepo/.github/workflows/pr-preview.yml b/project/aitoearn-monorepo/.github/workflows/pr-preview.yml new file mode 100644 index 000000000..e0f0a400a --- /dev/null +++ b/project/aitoearn-monorepo/.github/workflows/pr-preview.yml @@ -0,0 +1,330 @@ +name: Backend PR Preview Environment + +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +permissions: + id-token: write + contents: read + pull-requests: write + +env: + AWS_REGION: ap-southeast-1 + ECR_REGISTRY: 339388639667.dkr.ecr.ap-southeast-1.amazonaws.com + IMAGE_REPO: aitoearn + INFRASTRUCTURE_REPO: terraform + +jobs: + # 构建并推送镜像 + build-and-push: + if: github.event.action != 'closed' + runs-on: ubuntu-latest + + outputs: + apps: ${{ steps.generate-apps.outputs.apps }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 使用 Nx 检测受影响的应用 + - name: Detect affected apps + id: detect-changes + run: | + # 设置 base 分支 + BASE_REF="origin/${{ github.base_ref }}" + + echo "🔍 检测受影响的应用..." + echo "Base: $BASE_REF" + echo "Head: HEAD" + + AFFECTED_APPS=$(pnpm nx show projects --affected --type=app --sep "," --exclude="@yikart/source" --exclude="browser-automation-worker" --base=$BASE_REF --head=HEAD 2>/dev/null || echo "") + + if [ -z "$AFFECTED_APPS" ]; then + echo "⚠️ 未检测到受影响的应用" + echo "changed_apps=" >> $GITHUB_OUTPUT + else + echo "✅ 受影响的应用: $AFFECTED_APPS" + echo "changed_apps=$AFFECTED_APPS" >> $GITHUB_OUTPUT + + echo "" + echo "📊 详细信息:" + for APP in $(echo "$AFFECTED_APPS"); do + echo " - $APP" + done + fi + + - name: Configure AWS credentials + if: steps.detect-changes.outputs.changed_apps != '' + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_ROLE_ARN }} + aws-region: ${{ env.AWS_REGION }} + + - name: Login to Amazon ECR + if: steps.detect-changes.outputs.changed_apps != '' + uses: aws-actions/amazon-ecr-login@v2 + + - name: Set up Docker Buildx + if: steps.detect-changes.outputs.changed_apps != '' + uses: docker/setup-buildx-action@v3 + + - name: Generate version + id: version + run: | + VERSION="$(date +%Y%m%d)-$(git rev-parse --short HEAD)" + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "📦 版本: $VERSION" + + - name: Build and push Docker images + if: steps.detect-changes.outputs.changed_apps != '' + run: | + VERSION="${{ steps.version.outputs.version }}" + IFS=',' read -ra APPS <<< "${{ steps.detect-changes.outputs.changed_apps }}" + + for APP in "${APPS[@]}"; do + echo "🔨 构建应用: $APP" + + # 构建应用 + pnpm nx run $APP:build + + # 准备 Docker context + pnpm nx run $APP:docker-context + + echo "🐋 构建 Docker 镜像: $APP" + + IMAGE_TAG="${{ env.ECR_REGISTRY }}/${{ env.IMAGE_REPO }}/$APP:$VERSION" + + # 使用 docker/build-push-action 的等效命令,但支持循环 + docker buildx build \ + --platform linux/amd64 \ + --file ./tmp/docker-context/Dockerfile \ + --build-arg APP_NAME=$APP \ + --tag $IMAGE_TAG \ + --push \ + --cache-from type=gha,scope=$APP \ + --cache-to type=gha,mode=max,scope=$APP \ + --provenance=false \ + --sbom=false \ + ./tmp/docker-context + + echo "✅ 推送镜像: $IMAGE_TAG" + done + + # 生成应用配置 + - name: Generate apps configuration + if: steps.detect-changes.outputs.changed_apps != '' + id: generate-apps + run: | + VERSION="${{ steps.version.outputs.version }}" + IFS=',' read -ra APPS <<< "${{ steps.detect-changes.outputs.changed_apps }}" + + # 固定的健康检查路径 + HEALTH_PATH="/health" + + # 构建 JSON 配置 + APPS_JSON="{" + FIRST=true + + for APP in "${APPS[@]}"; do + if [ "$FIRST" = true ]; then + FIRST=false + else + APPS_JSON="$APPS_JSON," + fi + + CONFIG_FILE="apps/$APP/config/config.js" + + if [ ! -f "$CONFIG_FILE" ]; then + echo "❌ 错误: 应用 $APP 的配置文件不存在: $CONFIG_FILE" + exit 1 + fi + + PORT=$(grep -oP 'port:\s*\K\d+' "$CONFIG_FILE" 2>/dev/null || echo "") + + if [ -z "$PORT" ]; then + echo "❌ 错误: 无法从 $CONFIG_FILE 读取端口号" + echo " 请确保配置文件中包含 'port: <端口号>' 格式" + exit 1 + fi + + echo " ✓ $APP 端口: $PORT" + + INJECT_SOURCES=() + if grep -qE '^\s*redis\s*:' "$CONFIG_FILE"; then + INJECT_SOURCES+=("redis") + fi + if grep -qE '^\s*mongodb\s*:' "$CONFIG_FILE"; then + INJECT_SOURCES+=("mongodb") + fi + + # 转换为 JSON 数组格式 + INJECT_SOURCES_JSON=$(printf '"%s",' "${INJECT_SOURCES[@]}" | sed 's/,$//') + INJECT_SOURCES_JSON="[${INJECT_SOURCES_JSON}]" + + # 直接写入 APP_JSON + APP_JSON="{\"image\":\"${{ env.ECR_REGISTRY }}/${{ env.IMAGE_REPO }}/$APP:$VERSION\",\"port\":\"$PORT\",\"config_path\":\"$CONFIG_FILE\",\"health_check\":{\"path\":\"$HEALTH_PATH\"},\"inject_sources\":$INJECT_SOURCES_JSON}" + + APPS_JSON="$APPS_JSON\"$APP\":$APP_JSON" + done + + APPS_JSON="$APPS_JSON}" + + echo "apps<> $GITHUB_OUTPUT + echo "$APPS_JSON" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + echo "" + echo "📦 应用配置:" + echo "$APPS_JSON" | jq . + + # 创建或更新预览环境 + create-or-update-environment: + needs: build-and-push + if: github.event.action != 'closed' && needs.build-and-push.outputs.apps != '' + runs-on: ubuntu-latest + + steps: + - uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ env.INFRASTRUCTURE_REPO }} + + # 检查是否已存在预览环境 + - name: Check if environment exists + id: check-env + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const prNumber = context.payload.pull_request.number; + const branchName = `env/pr-${prNumber}`; + + try { + // 查询 infrastructure 仓库的 PR + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: 'infrastructure', + state: 'open', + head: `${context.repo.owner}:${branchName}` + }); + + const exists = prs.length > 0; + + if (exists) { + console.log(`✅ 预览环境已存在: PR #${prs[0].number}`); + core.setOutput('exists', 'true'); + core.setOutput('event_type', 'pr-update'); + } else { + console.log('🆕 需要创建新的预览环境'); + core.setOutput('exists', 'false'); + core.setOutput('event_type', 'pr-create'); + } + } catch (error) { + console.log('⚠️ 检查环境时出错,默认为创建新环境'); + core.setOutput('exists', 'false'); + core.setOutput('event_type', 'pr-create'); + } + + # 触发 infrastructure 仓库创建或更新环境 + - name: Trigger environment deployment + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const eventType = '${{ steps.check-env.outputs.event_type }}'; + const owner = '${{ github.repository_owner }}' + const repo = '${{ env.INFRASTRUCTURE_REPO }}' + const apps = ${{ needs.build-and-push.outputs.apps }}; + + await github.rest.repos.createDispatchEvent({ + owner: owner, + repo: repo, + event_type: eventType, + client_payload: { + pr_number: '${{ github.event.pull_request.number }}', + source_repo: '${{ github.repository }}', + apps: apps + } + }); + + console.log(`✅ 已触发 ${eventType} 事件`); + console.log('Payload:', { + pr_number: '${{ github.event.pull_request.number }}', + source_repo: '${{ github.repository }}', + apps: apps + }); + + # 销毁预览环境 + destroy-environment: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + + steps: + - uses: actions/create-github-app-token@v2 + id: generate-token + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ env.INFRASTRUCTURE_REPO }} + + # 关闭 infrastructure 仓库的 PR(会触发销毁) + - name: Close infrastructure PR + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const prNumber = context.payload.pull_request.number; + const branchName = `env/pr-${prNumber}`; + + try { + // 查找 infrastructure 仓库的对应 PR + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: 'infrastructure', + state: 'open', + head: `${context.repo.owner}:${branchName}` + }); + + if (prs.length > 0) { + const infraPR = prs[0]; + + // 关闭 PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: 'infrastructure', + pull_number: infraPR.number, + state: 'closed' + }); + + console.log(`✅ 已关闭 infrastructure PR #${infraPR.number}`); + } else { + console.log('⚠️ 未找到对应的 infrastructure PR,可能已被删除'); + } + } catch (error) { + console.error('❌ 关闭 PR 失败:', error.message); + // 不抛出错误,因为 PR 可能已经被手动关闭 + } + + diff --git a/project/aitoearn-monorepo/.github/workflows/release.yml b/project/aitoearn-monorepo/.github/workflows/release.yml new file mode 100644 index 000000000..6c3536e08 --- /dev/null +++ b/project/aitoearn-monorepo/.github/workflows/release.yml @@ -0,0 +1,73 @@ +name: Release Application + +on: + push: + tags: + - 'v*.*.*' # 触发条件:推送版本标签,如 v1.0.0 + +env: + APP_NAME: browser-automation-worker + RELEASE_TAG: ${{ github.ref_name }} + +jobs: + build-and-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build application + run: | + npx nx run ${{ env.APP_NAME }}:build --no-cloud + echo "Build completed, checking output directory..." + ls -la dist/apps/${{ env.APP_NAME }}/ + echo "Checking package.json dependencies..." + cat dist/apps/${{ env.APP_NAME }}/package.json + + - name: Create release package + run: | + cp .npmrc dist/apps/${{ env.APP_NAME }}/ + tar -czf ${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}.tar.gz -C dist/apps/${{ env.APP_NAME }} . + echo "Package created: ${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}.tar.gz" + ls -la ${{ env.APP_NAME }}-*.tar.gz + + - name: Generate Release Notes + id: generate_release_notes + run: | + if [ "${{ github.event_name }}" = "push" ]; then + PREVIOUS_TAG=$(git describe --tags --abbrev=0 `git rev-list --tags --skip=1 --max-count=1`) + echo "Previous tag: $PREVIOUS_TAG" + LOG=$(git log $PREVIOUS_TAG..${{ env.RELEASE_TAG }} --pretty=format:"* %s (%h)") + else + LOG="Manual release triggered for ${{ env.APP_NAME }}" + fi + echo "CHANGELOG<> $GITHUB_ENV + echo -e "## What's Changed\n\n$LOG" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create ${{ env.RELEASE_TAG }} \ + --title "${{ env.APP_NAME }} ${{ env.RELEASE_TAG }}" \ + --notes "${{ env.CHANGELOG }}" \ + ${{ env.APP_NAME }}-${{ env.RELEASE_TAG }}.tar.gz diff --git a/project/aitoearn-monorepo/.gitignore b/project/aitoearn-monorepo/.gitignore new file mode 100644 index 000000000..f433ca460 --- /dev/null +++ b/project/aitoearn-monorepo/.gitignore @@ -0,0 +1,48 @@ +# See https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files for more about ignoring files. + +# compiled output +dist +tmp +out-tsc + +# dependencies +node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +yarn-error.log +testem.log +/typings + +# System Files +.DS_Store +Thumbs.db + +.nx/cache +.nx/workspace-data +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md + +local.config.js +.prettierignore +.prettierrc diff --git a/project/aitoearn-monorepo/.npmrc b/project/aitoearn-monorepo/.npmrc new file mode 100644 index 000000000..33d66d721 --- /dev/null +++ b/project/aitoearn-monorepo/.npmrc @@ -0,0 +1,5 @@ +registry=https://registry.npmjs.org/ +auto-install-peers=true + +@yikart:registry=https://npm.pkg.github.com/ +//npm.pkg.github.com/:_authToken=\${GITHUB_TOKEN} \ No newline at end of file diff --git a/project/aitoearn-monorepo/.vscode/extensions.json b/project/aitoearn-monorepo/.vscode/extensions.json new file mode 100644 index 000000000..6a302fe53 --- /dev/null +++ b/project/aitoearn-monorepo/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "nrwl.angular-console", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "firsttris.vscode-jest-runner" + ] +} diff --git a/project/aitoearn-monorepo/.vscode/launch.json b/project/aitoearn-monorepo/.vscode/launch.json new file mode 100644 index 000000000..5a65c9eba --- /dev/null +++ b/project/aitoearn-monorepo/.vscode/launch.json @@ -0,0 +1,85 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug fingerprint-manager with Nx", + "runtimeExecutable": "pnpm exec", + "runtimeArgs": ["nx", "serve", "fingerprint-manager"], + "env": { + "NODE_OPTIONS": "--inspect=9229" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/fingerprint-manager/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug browser-automation-worker with Nx", + "runtimeExecutable": "pnpm exec", + "runtimeArgs": ["nx", "serve", "browser-automation-worker"], + "env": { + "NODE_OPTIONS": "--inspect=9230" + }, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/browser-automation-worker/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug Channel with Nx", + "runtimeExecutable": "npx", + "runtimeArgs": ["nx", "serve", "aitoearn-channel", "--inspect-brk"], + "env": { + "NODE_ENV": "local", + }, + "restart": true, + "stopOnEntry": false, + "continueOnAttach": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/aitoearn-channel/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] + }, + { + "type": "node", + "request": "launch", + "name": "Debug Gateway with Nx", + "runtimeExecutable": "npx", + "runtimeArgs": ["nx", "serve", "aitoearn-server", "--inspect-brk"], + "env": { + "NODE_ENV": "local", + }, + "restart": true, + "stopOnEntry": false, + "continueOnAttach": true, + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "cwd": "${workspaceFolder}", + "skipFiles": ["/**"], + "sourceMaps": true, + "outFiles": [ + "${workspaceFolder}/apps/aitoearn-server/dist/**/*.(m|c|)js", + "!**/node_modules/**" + ] + } + ] +} diff --git a/project/aitoearn-monorepo/.vscode/settings.json b/project/aitoearn-monorepo/.vscode/settings.json new file mode 100644 index 000000000..ee6e092df --- /dev/null +++ b/project/aitoearn-monorepo/.vscode/settings.json @@ -0,0 +1,53 @@ +{ + // Disable the default formatter, use eslint instead + "prettier.enable": false, + "editor.formatOnSave": false, + + // Auto fix + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.organizeImports": "never" + }, + + // Silent the stylistic rules in you IDE, but still auto fix them + "eslint.rules.customizations": [ + { "rule": "style/*", "severity": "off", "fixable": true }, + { "rule": "format/*", "severity": "off", "fixable": true }, + { "rule": "*-indent", "severity": "off", "fixable": true }, + { "rule": "*-spacing", "severity": "off", "fixable": true }, + { "rule": "*-spaces", "severity": "off", "fixable": true }, + { "rule": "*-order", "severity": "off", "fixable": true }, + { "rule": "*-dangle", "severity": "off", "fixable": true }, + { "rule": "*-newline", "severity": "off", "fixable": true }, + { "rule": "*quotes", "severity": "off", "fixable": true }, + { "rule": "*semi", "severity": "off", "fixable": true } + ], + + // Enable eslint for all supported languages + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue", + "html", + "markdown", + "json", + "json5", + "jsonc", + "yaml", + "toml", + "xml", + "gql", + "graphql", + "astro", + "svelte", + "css", + "less", + "scss", + "pcss", + "postcss" + ], + "javascript.preferences.importModuleSpecifier": "project-relative", + "typescript.preferences.importModuleSpecifier": "project-relative" +} diff --git a/project/aitoearn-monorepo/DEVELOPER_GUIDE.md b/project/aitoearn-monorepo/DEVELOPER_GUIDE.md new file mode 100644 index 000000000..7702ae1d6 --- /dev/null +++ b/project/aitoearn-monorepo/DEVELOPER_GUIDE.md @@ -0,0 +1,679 @@ +# 项目开发指南 + +欢迎来到 aitoearn-monorepo 项目!本指南旨在帮助新加入的开发者快速了解项目架构、开发流程和代码规范,从而能够高效地参与到项目中来。 + +## 目录 + +- [项目开发指南](#项目开发指南) + - [目录](#目录) + - [1. 项目架构](#1-项目架构) + - [1.1. Monorepo 与 Nx](#11-monorepo-与-nx) + - [1.2. IDE 插件](#12-ide-插件) + - [1.3. 核心目录结构](#13-核心目录结构) + - [2. 开发入门](#2-开发入门) + - [2.1. 快速上手](#21-快速上手) + - [2.2. 环境准备](#22-环境准备) + - [2.3. 核心开发命令](#23-核心开发命令) + - [2.4. 日常开发流程](#24-日常开发流程) + - [3. 新增子项目与发布](#3-新增子项目与发布) + - [3.1. 安装 Nx 插件](#31-安装-nx-插件) + - [3.2. 使用自定义生成器](#32-使用自定义生成器) + - [3.3. 使用标准生成器](#33-使用标准生成器) + - [3.4. 发布包到 GitHub Packages](#34-发布包到-github-packages) + - [3.4.1. 配置 `project.json`](#341-配置-projectjson) + - [3.4.2. 执行发布](#342-执行发布) + - [4. 代码规范](#4-代码规范) + - [4.1. 代码风格与 Linting](#41-代码风格与-linting) + - [4.2. 日志记录](#42-日志记录) + - [4.2.1. 参数使用与 JSON 输出](#421-参数使用与-json-输出) + - [5. 微服务架构](#5-微服务架构) + - [5.1. 基于 NATS 的通信](#51-基于-nats-的通信) + - [5.2. 客户端库模式](#52-客户端库模式) + - [5.3. 如何使用客户端](#53-如何使用客户端) + - [6. CI/CD](#6-cicd) + - [6.1 工作流程(Workflows)](#61-工作流程workflows) + - [6.2 如何运作](#62-如何运作) + - [6.3 可视化流程图](#63-可视化流程图) + - [7. Docker](#7-docker) + - [7.1. Dockerfile](#71-dockerfile) + - [7.2. 构建和运行单个服务](#72-构建和运行单个服务) + - [8. 环境配置](#8-环境配置) + - [8.1. 配置文件](#81-配置文件) + - [8.2. 配置加载](#82-配置加载) + - [8.3 如何访问配置](#83-如何访问配置) + - [9. 公共库说明](#9-公共库说明) + - [9.1. 核心库详解 (`common` & `mongodb`)](#91-核心库详解-common--mongodb) + - [9.1.1. `common` 库](#911-common-库) + - [代码风格与核心原则](#代码风格与核心原则) + - [核心目录结构与功能](#核心目录结构与功能) + - [9.1.2. `mongodb` 库](#912-mongodb-库) + - [代码风格与核心原则-1](#代码风格与核心原则-1) + - [核心目录结构与功能-1](#核心目录结构与功能-1) + - [9.2. 其他公共库](#92-其他公共库) + +## 1. 项目架构 + +### 1.1. Monorepo 与 Nx + +本项目采用 **Monorepo**(单一代码库)的组织方式,将所有的应用和库都放在一个代码仓库中。这种方式便于代码共享、原子化提交以及统一的依赖管理。 + +我们使用 [**Nx**](https://nx.dev/) 作为 Monorepo 的管理和构建工具。Nx 提供了以下核心优势: + +- **智能构建**: Nx 能够分析项目间的依赖关系,只构建受变更影响的部分。 +- **任务缓存**: 对构建等任务的结果进行缓存,极大提升了重复执行任务的速度。 +- **代码生成**: 提供强大的代码生成器,可以快速创建新的应用和库。 +- **统一的命令**: 通过 `nx` 命令行工具,可以方便地在所有项目上执行相同的任务。 + +### 1.2. IDE 插件 + +为了获得更好的开发体验,强烈建议安装官方的 **Nx Console** IDE 插件。 + +- **VS Code**: [Nx Console on VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=nrwl.angular-console) +- **JetBrains IDEs (WebStorm, IntelliJ IDEA)**: 在 IDE 的插件市场中搜索 "Nx Console" 进行安装。 + +该插件提供了以下便利功能: + +- **可视化操作**: 通过图形化界面来运行 `nx` 命令(如 `serve`, `build`, `lint`)。 +- **项目依赖图**: 可视化展示项目之间的依赖关系,帮助你更好地理解项目结构。 +- **代码生成器**: 直接在 IDE 中使用代码生成器,无需手动输入命令。 + +### 1.3. 核心目录结构 + +``` +aitoearn-monorepo/ +├── apps/ # 存放各个独立的应用程序 +│ ├── aitoearn-ai/ +│ ├── aitoearn-user/ +│ └── ... +├── libs/ # 存放可在多个应用之间共享的库 +│ ├── aitoearn-user-client/ # 用于和 aitoearn-user 服务通信的客户端 +│ ├── common/ # 通用工具和类型定义 +│ └── ... +├── tools/ # 存放开发工具和脚本 +├── nx.json # Nx 的核心配置文件 +├── package.json # 项目的依赖和脚本 +├── pnpm-workspace.yaml # 定义 pnpm 的工作区 +└── tsconfig.base.json # TypeScript 的基础配置 +``` + +- **`apps/`**: 包含所有可独立部署的应用程序。每个应用都是一个 Nest.js 或 Node.js 项目。 +- **`libs/`**: 包含所有共享的代码。这可以是功能性的库(如 `@yikart/aws-s3`)、UI 组件库,或者用于微服务通信的客户端库(如 `@yikart/aitoearn-user-client`)。 + +## 2. 开发入门 + +### 2.1. 快速上手 + +以下步骤帮助你在本地快速运行任意一个应用(以 aitoearn-ai 为例): + +1) 安装依赖 + +```bash +pnpm install +``` + +2) 启动开发服务器(开发配置) + +```bash +nx serve aitoearn-ai --configuration=dev +``` + +3) 构建单个应用或库(可选) + +```bash +# 构建应用 +nx build aitoearn-ai + +# 构建库示例 +nx build aws-s3 +``` + +4) 构建 Docker 镜像(可选) + +```bash +pnpm nx run aitoearn-cloud-space:docker-build +# 或 +node scripts/build-docker.mjs aitoearn-cloud-space +``` + +5) 预览发布(可选) + +```bash +pnpm nx release --dry-run +``` + +完成以上步骤后,即可在本地迭代开发。如果你的应用需要环境配置,请参考“环境配置”章节的 8.1/8.2/8.3。 + +### 2.2. 环境准备 + +1. **安装 Node.js**: 确保你的 Node.js 版本符合 `package.json` 中 `engines` 字段的要求。 +2. **安装 pnpm**: 本项目使用 `pnpm` 作为包管理工具。通过 `npm install -g pnpm` 进行安装。 +3. **安装依赖**: 在项目根目录运行以下命令来安装所有依赖: + + ```bash + pnpm install + ``` + +### 2.3. 核心开发命令 + +在本项目中,我们主要使用 `nx` 命令行工具来执行各种开发任务。基本语法为 `nx `。 + +- **运行开发服务器**: + + ```bash + # 运行 aitoearn-ai 应用 + nx serve aitoearn-ai + ``` + + 该命令会启动应用,并监听文件变化以实现热重载。你可以通过 `--configuration` 参数来指定不同的环境配置,例如 `nx serve aitoearn-ai --configuration=dev`。 + +- **构建项目**: + + ```bash + # 构建 aitoearn-ai 应用 + nx build aitoearn-ai + + # 构建 aws-s3 库 + nx build aws-s3 + ``` + + 构建产物会输出到 `dist/` 目录下。 + +- **代码检查 (Linting)**: + + ```bash + # 检查 aitoearn-ai 应用的代码规范 + nx lint aitoearn-ai + + # 检查所有项目的代码规范 + pnpm lint + ``` + +### 2.4. 日常开发流程 + +1. **切换到你的开发分支**: `git checkout -b feature/my-new-feature` +2. **启动应用**: `nx serve ` +3. **编写代码**: 在 `apps/` 或 `libs/` 中进行修改。如果你修改了一个被其他项目依赖的库,Nx 会在需要时自动重新构建它。 +4. **运行 Lint**: `nx lint `,确保没有代码规范问题。 +5. **提交代码**: `git commit -m "feat: implement my new feature"` +6. **发起合并请求**: 推送你的分支并发起一个 Pull Request。 + +## 3. 新增子项目与发布 + +Nx 提供了强大的代码生成功能,可以帮助我们快速创建新的应用和库。 + +### 3.1. 安装 Nx 插件 + +如果需要的功能(例如 Remix、Vite 等)不由默认安装的插件提供,你可以从 Nx 插件市场安装新的插件。 + +```bash +# 安装 @nx/react 插件 +pnpm add -D @nx/react +``` + +安装后,你可以使用 `nx list ` 查看该插件提供了哪些生成器和执行器。 + +```bash +nx list @nx/react +``` + +### 3.2. 使用自定义生成器 + +本项目提供了一个自定义的应用生成器 `@yikart/app-generator:app`,这是创建新应用时的**首选方式**,因为它会为你配置好所有项目特定的规范和基础代码。 + +```bash +nx generate @yikart/app-generator:app +``` + +### 3.3. 使用标准生成器 + +如果你需要创建标准的 Nest.js 应用、Node.js 应用或通用库,可以使用 Nx 提供的标准生成器: + +- **新增 Nest.js 应用**: + + ```bash + nx generate @nx/nest:application + ``` + +- **新增 Node.js 应用**: + + ```bash + nx generate @nx/node:application + ``` + +- **新增通用库 (TypeScript)**: + + ```bash + nx generate @nx/js:library + ``` + +### 3.4. 发布包到 GitHub Packages + +当一个库开发完成并准备好被其他项目使用时,可以将其发布到私有的 npm 注册表(本项目使用 GitHub Packages)。发布流程由 Nx Release 插件管理,并已在根目录的 `nx.json` 中进行了全局配置。 + +#### 3.4.1. 配置 `project.json` + +要使一个库可以被发布,需要在其 `project.json` 文件中添加 `release` 和 `nx-release-publish` 配置。可以参考 `libs/aws-s3/project.json` 的配置: + +```json +{ + "name": "my-new-lib", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/my-new-lib/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "targets": { + "build": { + // ... build configuration + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + // ... lint configuration + } + } +} +``` + +**关键点**: + +- `release.version.generatorOptions.packageRoot`: 定义了版本更新时需要修改 `package.json` 的位置,应指向构建产物目录。 +- `targets.nx-release-publish`: 这是实际执行发布的任务。它的 `packageRoot` 选项也必须指向包含 `package.json` 的构建产物目录。 + +#### 3.4.2. 执行发布 + +配置完成后,当你的代码合并到主分支后,可以通过以下命令来发布一个或多个包: + +```bash +# 预览将要发布的版本和包 +pnpm nx release --dry-run + +# 正式发布 +pnpm nx release +``` + +Nx 会自动检测自上次发布以来有代码变更的库,计算下一个版本号,构建这些库,然后将它们发布到 GitHub Packages。 + +## 4. 代码规范 + +### 4.1. 代码风格与 Linting + +- **ESLint**: 项目使用 ESLint 进行代码规范检查,配置基于 `@antfu/eslint-config`,提供了一套严格且现代化的代码风格指南。 +- **自动修复**: 运行 `nx lint --fix` 可以自动修复大部分格式问题。 +- **提交检查**: 项目配置了 `lint-staged` 和 `simple-git-hooks`,会在 `git commit` 时自动对暂存区的文件进行代码规范检查,确保所有提交到代码库的代码都符合规范。 + +### 4.2. 日志记录 + +- **使用 Nest.js 内置 Logger**: 所有服务都应该使用 `@nestjs/common` 提供的 `Logger` 服务来记录日志。 +- **实例化 Logger**: 必须在类的构造函数中或作为属性初始化时创建 `Logger` 的实例,并传入当前的类名,以便追溯日志来源。 + +- **日志级别和格式**: Logger 的方法(如 `verbose`, `debug`, `log` 等)的参数格式与 `pino` 类似。为了满足 NestJS 的 `LoggerService` 接口,`pino` 的 `trace`/`info` 级别分别对应 `LoggerService` 的 `verbose`/`log`。 + + ```typescript + import { Injectable, Logger } from '@nestjs/common' + + @Injectable() + export class MyService { + private readonly logger = new Logger(MyService.name) + + foo() { + // 所有 logger 方法的参数格式都与 pino 相同, + // 但 pino 的 `trace` 和 `info` 方法被映射为 `verbose` 和 `log` 以满足 NestJS 的 `LoggerService` 接口 + this.logger.verbose({ foo: 'bar' }, 'baz %s', 'qux') + this.logger.debug('foo %s %o', 'bar', { baz: 'qux' }) + this.logger.log('foo') + } + } + ``` + +#### 4.2.1. 参数使用与 JSON 输出 + +`pino`-style 的日志记录方式允许你传入一个可选的对象作为第一个参数,用于添加额外的上下文信息到日志中。消息模板和参数会跟在后面。 + +下面是上述代码示例中每个日志调用对应的 JSON 输出: + +1. `this.logger.verbose({ foo: 'bar' }, 'baz %s', 'qux');` + - 第一个参数 `{ foo: 'bar' }` 是一个对象,它的属性会被合并到 JSON 日志的根级别。 + - 第二个参数 `'baz %s'` 是消息模板。 + - 第三个参数 `'qux'` 会替换掉模板中的 `%s`。 + - **输出 (Verbose, Level 10):** + ```json + { "level": 10, "time": 1629823792023, "pid": 15067, "hostname": "my-host", "context": "MyService", "foo": "bar", "msg": "baz qux" } + ``` + +2. `this.logger.debug('foo %s %o', 'bar', { baz: 'qux' });` + - 第一个参数是消息模板。 + - `%s` 会被 `'bar'` 替换。 + - `%o` 会将对象 `{ baz: 'qux' }` 序列化为字符串。 + - **输出 (Debug, Level 20):** + ```json + { "level": 20, "time": 1629823792023, "pid": 15067, "hostname": "my-host", "context": "MyService", "msg": "foo bar {\"baz\":\"qux\"}" } + ``` + +3. `this.logger.log('foo');` + - 只有一个字符串参数,它会成为日志的 `msg` 字段。 + - **输出 (Log, Level 30):** + ```json + { "level": 30, "time": 1629823792023, "pid": 15067, "hostname": "my-host", "context": "MyService", "msg": "foo" } + ``` + +## 5. 微服务架构 + +### 5.1. 基于 NATS 的通信 + +项目中的微服务通信是基于 [**NATS**](https://nats.io/) 实现的,但我们并不直接与 NATS API 交互,而是通过一个专门封装的库 `@yikart/nats-client` 来简化通信。 + +`@yikart/nats-client` 库的核心目标是提供一个简单、统一的接口来处理服务间的调用,它将 NATS 的两种主要通信模式封装为两个核心方法: + +1. **`natsClient.send()` (请求/响应)**: 用于服务间的请求/响应调用。当你需要调用另一个服务的功能并获取其返回结果时,就使用此方法。它封装了 NATS 的 `request-reply` 模式,但开发者无需关心底层的 `subject` 创建、`subscription` 管理和超时的复杂性。 + + 在服务提供方,通过 Nest.js 的 `@MessagePattern()` 装饰器来监听并处理来自 `send()` 的请求。 + +2. **`natsClient.emit()` (事件发布)**: 用于发布事件,即“即发即忘”(fire-and-forget) 的消息。当你需要通知其他一个或多个服务发生了某个事件,但不需要它们的响应时,使用此方法。它封装了 NATS 的 `publish-subscribe` 模式。 + + 在事件接收方,通过 `@EventPattern()` 装饰器来订阅并处理这些事件。 + +这种封装使得业务代码可以完全不感知 NATS 的存在,只需注入相应的客户端库(如 `AitoearnUserClient`)并调用其方法即可,极大地降低了微服务开发的复杂性。 + +### 5.2. 客户端库模式 + +为了简化服务间的调用,我们为每个提供接口的服务(如 `aitoearn-user`)都创建了一个对应的客户端库(如 `aitoearn-user-client`)。这个客户端库封装了所有与 NATS 通信的底层细节。 + +该模式的核心是: + +1. **`NatsClient`**: 一个通用的 NATS 客户端,封装了 `send` 和 `publish` 等方法。 +2. **动态模块**: 客户端库本身是一个 Nest.js 的动态模块,允许在使用时注入 NATS 的配置。 +3. **强类型接口**: 使用 TypeScript 定义了所有请求和响应的数据结构(DTO),提供了编译时的类型安全检查。 + +### 5.3. 如何使用客户端 + +要在 `aitoearn-ai` 服务中调用 `aitoearn-user` 服务的接口,你需要: + +1. **导入客户端模块**: 在 `aitoearn-ai` 的 `app.module.ts` 中导入 `AitoearnUserClientModule`。 + + ```typescript + // apps/aitoearn-ai/src/app.module.ts + import { AitoearnUserClientModule } from '@yikart/aitoearn-user-client' + + @Module({ + imports: [ + AitoearnUserClientModule.forRoot(config.nats), + // ... + ], + }) + export class AppModule {} + ``` + +2. **注入客户端服务**: 在需要调用用户服务的 service 中,通过依赖注入来使用 `AitoearnUserClient`。 + + ```typescript + // apps/aitoearn-ai/src/some.service.ts + import { Injectable } from '@nestjs/common' + import { AitoearnUserClient } from '@yikart/aitoearn-user-client' + + @Injectable() + export class SomeService { + constructor(private readonly userClient: AitoearnUserClient) {} + + async getUser(userId: string) { + const user = await this.userClient.getUserInfoById({ id: userId }) + return user + } + } + ``` + +## 6. CI/CD + +本项目的持续集成与持续部署(CI/CD)流程基于 **GitHub Actions**,旨在实现代码提交、构建和部署的完全自动化,确保代码质量和交付效率。 + +#### 6.1 工作流程(Workflows) + +CI/CD 流程由定义在 `.github/workflows` 目录下的两个核心文件驱动: + +- **`release.yml`**: 该工作流程负责在 `main` 分支有新的 `push` 操作时,自动执行以下任务: + - **构建**: 编译所有服务和库。 + - **版本管理**: 根据提交信息自动生成版本号(Semantic Versioning)。 + - **发布**: 创建一个新的 GitHub Release,并将构建产物打包发布到 GitHub Packages。 + +- **`deploy.yml`**: 该工作流程可通过 `workflow_dispatch` 手动触发,用于构建和部署单个服务。它会构建 Docker 镜像,推送到 Amazon ECR,然后创建一个拉取请求到 Helm 仓库以更新部署。 + - **部署触发**: 在 GitHub Actions 页面手动选择目标应用、环境和版本来触发部署。 + - **服务更新**: 工作流会在 Helm 仓库中创建一个包含新镜像标签的拉取请求。当该 PR 被合并后,部署在 Kubernetes 上的应用将自动更新到新版本。 + +#### 6.2 如何运作 + +1. **代码提交**: 开发者将代码推送到 `main` 分支。 +2. **自动构建**: `release.yml` 捕捉到 `push` 事件,开始执行构建任务。 +3. **发布新版本**: 如果所有任务成功,将自动创建一个新的 Release,并发布到 GitHub Packages。 +4. **手动部署**: 开发者在 GitHub Actions 页面手动触发 `deploy.yml` 工作流程,选择要部署的应用、环境和版本。工作流将构建新的 Docker 镜像,推送到 ECR,并创建 PR 到 Helm 仓库。PR 合并后,应用将自动更新。 + +#### 6.3 可视化流程图 + +Mermaid 流程图(在支持 Mermaid 的平台可视化展示): + +```mermaid +flowchart TD + DevPush[开发者 Push 到 main] --> ReleaseYML[GitHub Actions: release.yml] + ReleaseYML --> Build[构建应用与库] + ReleaseYML --> Version[版本管理] + ReleaseYML --> Publish[发布到 GitHub Packages] + + ManualTrigger[开发者在 Actions 手动触发 deploy.yml] --> DeployYML[GitHub Actions: deploy.yml] + DeployYML --> DockerBuild[构建 Docker 镜像] + DockerBuild --> ECR[推送到 Amazon ECR] + DeployYML --> HelmPR[创建 PR 到 Helm 仓库] + HelmPR --> Merge[PR 合并] + Merge --> K8sUpdate[Kubernetes 自动更新部署] +``` + +--- + +### 7. Docker + +项目使用 Docker 来确保开发和生产环境的一致性。我们通过一个定制的脚本来简化和标准化 Docker 镜像的构建过程。 + +#### 7.1. Dockerfile + +每个需要容器化的应用都可以在其根目录下包含一个 `Dockerfile`。如果应用特定的 `Dockerfile` 不存在,将使用项目根目录下的通用 `Dockerfile`。 + +#### 7.2. 构建和运行单个服务 + +我们通过在 `project.json` 中为每个应用定义 `docker-build` 目标来标准化 Docker 镜像的构建过程。 + +**构建命令** + +该命令会读取 `project.json` 中的 `targets.docker-build.options` 配置,并执行 `docker build` 命令。你也可以直接执行以下命令来构建 Docker 镜像: + +```bash +pnpm nx run :docker-build +``` + +- ``: 你想要构建镜像的应用名称(例如 `aitoearn-user`)。 + +**示例** + +为 `aitoearn-cloud-space` 应用构建镜像: + +```bash +node scripts/build-docker.mjs aitoearn-cloud-space +``` + +构建完成后,会生成一个带有日期和 Git 提交哈希的标签的镜像,例如 `aitoearn-cloud-space:20231027-a1b2c3d`。 + +### 8. 环境配置 + +本项目通过在应用或库的 `config` 目录下区分环境加载配置,并结合使用 `@yikart/common` 中的 `selectConfig` 函数来管理不同环境的配置。 + +#### 8.1. 配置文件 + +每个应用或库都可以包含一个 `config` 目录,用于存放不同环境的配置文件。例如,`aitoearn-cloud-space` 应用的配置结构如下: + +``` +apps/aitoearn-cloud-space/ +├── config/ +│ ├── dev.config.js +│ └── prod.config.js +└── src/ + ├── main.ts + └── config.ts +``` + +- `dev.config.js`: 开发环境的特定配置。 +- `prod.config.js`: 生产环境的特定配置。 + +这些配置文件导出一个对象,其中包含了该环境下的所有配置变量。配置可以从环境变量、硬编码值或其他来源获取。 + +#### 8.2. 配置加载 + +配置的加载和验证由 `@yikart/common` 库中的 `selectConfig` 函数和 Zod schema 共同完成。 + +1. **定义配置 Schema** + + 在 `src/config.ts` 文件中,我们使用 Zod 定义配置的结构和验证规则。这确保了配置的类型安全和完整性。 + + ```typescript + // apps/aitoearn-cloud-space/src/config.ts + import { baseConfig, createZodDto, selectConfig } from '@yikart/common'; + import { z } from 'zod'; + + export const appConfigSchema = z.object({ + ...baseConfig.shape, + // ... 其他特定于应用的配置 + }); + + export class AppConfig extends createZodDto(appConfigSchema) {} + + export const config = selectConfig(AppConfig); + ``` + +2. **启动应用并加载配置** + + 在应用入口文件 `src/main.ts` 中,我们首先通过 `selectConfig` 加载配置,然后将其传递给 `startApplication` 函数来初始化应用。 + + ```typescript + // apps/aitoearn-cloud-space/src/main.ts + import { startApplication } from '@yikart/common'; + import { AppModule } from './app.module'; + import { config } from './config'; + + startApplication(AppModule, config); + ``` + + `selectConfig` 函数负责从命令行参数中获取配置文件路径,并使用 Zod schema 进行验证。`startApplication` 则使用加载好的配置来初始化 Nest.js 应用的各个模块。 + +### 8.3 如何访问配置 + +配置是通过 `config` 对象直接传递给需要它的模块,通常是在 `AppModule` 中通过模块的 `forRoot` 静态方法。 + +例如,`MongodbModule` 接收数据库配置: + +```typescript +// app.module.ts +import { Module } from '@nestjs/common' +import { MongodbModule } from '@yikart/mongodb' +import { config } from './config' +// ... other imports + +@Module({ + imports: [ + MongodbModule.forRoot(config.mongodb), + // ... other modules + ], + // ... +}) +export class AppModule {} +``` + +在模块内部,服务可以通过依赖注入来访问这些配置。通常,`forRoot` 方法会动态地创建一个 provider,将配置值提供给模块内的服务。 + +对于需要访问应用级别配置(例如,在 `AppConfig` 中定义的)的服务,可以注入 `AppConfig`。 + +```typescript +import { Injectable } from '@nestjs/common' +import { AppConfig } from '../config' + +@Injectable() +export class MyService { + constructor(private readonly appConfig: AppConfig) {} + + someMethod() { + const value = this.appConfig.someValue; + // ... + } +} +``` + +## 9. 公共库说明 + +### 9.1. 核心库详解 (`common` & `mongodb`) + +接下来,我们将详细介绍 `common` 和 `mongodb` 这两个核心库的设计和使用方式。 + +#### 9.1.1. `common` 库 + +`@yikart/common` 是整个 Monorepo 的基石,提供了大量可复用的基础组件、工具函数和统一的编程规范。它的设计目标是提升开发效率、保证代码质量和项目一致性。 + +##### 代码风格与核心原则 + +- **模块化与可复用性**: 所有组件都应设计为高度模块化和可配置的,以便在不同项目中轻松复用。 +- **统一异常处理**: 使用自定义的异常过滤器(`GlobalExceptionFilter`)来捕获和格式化所有 HTTP 响应中的错误,确保客户端接收到结构一致的错误信息。 +- **DTO 与数据验证**: 强制使用 Zod-DTO 进行数据验证和转换,保证进入应用层的数据的有效性和类型安全。 +- **日志标准化**: 提供基于 `pino` 的日志模块,并与 Nest.js 的 `LoggerService` 集成,确保日志格式统一、可追溯。 +- **配置管理**: 通过 `ConfigModule` 集中管理环境变量和配置文件,实现不同环境的平滑切换。 + +##### 核心目录结构与功能 + +- **`decorators`**: 包含自定义装饰器,如 `@ApiDoc()` 用于生成 API 文档。 +- **`dtos` / `vos`**: 数据传输对象(DTOs)和视图对象(VOs),用于规范接口的输入和输出数据结构。 +- **`enums`**: 全局共享的枚举类型。 +- **`exceptions`**: 自定义异常类,如 `AppException` 和 `ZodValidationException`。 +- **`filters`**: 全局异常过滤器,如 `GlobalExceptionFilter`。 +- **`interceptors`**: 全局拦截器,如用于转换响应数据结构的 `ResponseInterceptor`。 +- **`loggers`**: 日志服务模块,支持控制台和 CloudWatch 输出。 +- **`pipes`**: 全局管道,如 `ZodValidationPipe` 用于自动验证 DTO。 +- **`starter.ts`**: 封装了 Nest.js 应用的启动逻辑,集成了日志、管道、过滤器等通用配置。 +- **`utils`**: 通用的工具函数集合。 + +#### 9.1.2. `mongodb` 库 + +`@yikart/mongodb` 库封装了与 MongoDB 数据库的交互逻辑,提供了一个结构化、可复用的数据访问层。它基于 Mongoose 构建,并增加了对事务、仓储模式(Repository Pattern)等的支持。 + +##### 代码风格与核心原则 + +- **仓储模式 (Repository Pattern)**: 每个 `Schema`(模式)都对应一个 `Repository`(仓储)。所有的数据库操作(增删改查)都应该在 `Repository` 中完成,而不是在业务逻辑(Service)中直接调用 Mongoose 的 `Model`。这使得数据访问逻辑与业务逻辑解藕,更易于验证与维护。 +- **Schema 定义**: Mongoose `Schema` 用于定义数据模型。我们约定在 `schemas` 目录下创建和管理这些模型。 +- **事务支持**: 提供了 `@Transactional()` 装饰器和 `TransactionalInjector`,可以方便地在需要原子操作的业务方法上启用 MongoDB 事务。 +- **统一配置**: 通过 `MongodbModule.forRoot()` 进行数据库连接配置,支持多数据库连接。 + +##### 核心目录结构与功能 + +- **`decorators`**: 包含 `@Transactional()` 等与数据库操作相关的装饰器。 +- **`repositories`**: 存放所有仓储类的基类和具体实现。通常会有一个 `BaseRepository` 来封装通用的 CRUD 操作。 +- **`schemas`**: 存放所有 Mongoose 的 `Schema` 定义。 +- **`transactional.injector.ts`**: 实现了事务装饰器背后的逻辑,通过 `AsyncLocalStorage` 来管理和传递事务会话(Session)。 +- **`mongodb.module.ts`**: 封装了 Mongoose 的连接和模块注册逻辑。 + +### 9.2. 其他公共库 + +- **`@yikart/aitoearn-ai-client`**: `aitoearn-ai` 服务的客户端库,用于调用 AI 相关功能,如对话、绘画等。 +- **`@yikart/aitoearn-user-client`**: `aitoearn-user` 服务的客户端库,用于获取和管理用户信息。 +- **`@yikart/ansible`**: 封装了 Ansible 操作的库,用于自动化部署和配置管理。 +- **`@yikart/aws-s3`**: 封装了 AWS S3 对象存储服务的客户端,提供文件上传、下载和管理功能。 +- **`@yikart/cloud-space-client`**: `cloud-space` 服务的客户端库,用于管理用户的云空间资源。 +- **`@yikart/multilogin`**: 封装了 Multilogin 服务,用于管理浏览器指纹和自动化操作。 +- **`@yikart/nats-client`**: 封装了 NATS 客户端,提供了统一的 `send` 和 `emit` 方法。 +- **`@yikart/redis`**: 封装了 Redis 客户端,提供缓存服务。 +- **`@yikart/redlock`**: 基于 Redis 实现了分布式锁,确保分布式环境下的资源访问互斥。 +- **`@yikart/stripe`**: 封装了 Stripe 支付服务的客户端,用于处理订阅、支付、优惠券等功能。 +- **`@yikart/ucloud`**: 封装了 UCloud 服务(如对象存储 US3、内容分发网络 UCDN 等)的客户端。 + +通过遵循这些库的设计原则和代码风格,我们可以确保项目在快速迭代的同时,依然保持高质量、高可维护性的代码标准。 diff --git a/project/aitoearn-monorepo/Dockerfile b/project/aitoearn-monorepo/Dockerfile new file mode 100644 index 000000000..bd462dba5 --- /dev/null +++ b/project/aitoearn-monorepo/Dockerfile @@ -0,0 +1,27 @@ +FROM node:22-alpine AS deps + +RUN npm install -g pnpm + +WORKDIR /app + +COPY deps/ ./ + +RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod + +FROM node:22-alpine AS production + +ARG APP_NAME + +WORKDIR /app + +COPY assets/ ./ +COPY --from=deps /app/ ./ + +COPY apps/ ./apps/ +COPY libs/ ./libs/ + + +ENV NODE_ENV=production +ENV APP_NAME=$APP_NAME + +CMD node apps/${APP_NAME}/src/main.js -c config.js diff --git a/project/aitoearn-monorepo/README.md b/project/aitoearn-monorepo/README.md new file mode 100644 index 000000000..08aab64f0 --- /dev/null +++ b/project/aitoearn-monorepo/README.md @@ -0,0 +1,82 @@ +# Aitoearn + + + +✨ Your new, shiny [Nx workspace](https://nx.dev) is almost ready ✨. + +[Learn more about this workspace setup and its capabilities](https://nx.dev/nx-api/nest?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or run `npx nx graph` to visually explore what was created. Now, let's get you up to speed! + +## Finish your CI setup + +[Click here to finish setting up your workspace!](https://cloud.nx.app/connect/8yzMdCojUQ) + +## Run tasks + +To run the dev server for your app, use: + +```sh +npx nx serve fingerprint-manager +``` + +To create a production bundle: + +```sh +npx nx build fingerprint-manager +``` + +To see all available targets to run for a project, run: + +```sh +npx nx show project fingerprint-manager +``` + +These targets are either [inferred automatically](https://nx.dev/concepts/inferred-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) or defined in the `project.json` or `package.json` files. + +[More about running tasks in the docs »](https://nx.dev/features/run-tasks?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Add new projects + +While you could add new projects to your workspace manually, you might want to leverage [Nx plugins](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) and their [code generation](https://nx.dev/features/generate-code?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) feature. + +Use the plugin's generator to create new projects. + +To generate a new application, use: + +```sh +npx nx g @nx/nest:app demo +``` + +To generate a new library, use: + +```sh +npx nx g @nx/node:lib mylib +``` + +You can use `npx nx list` to get a list of installed plugins. Then, run `npx nx list ` to learn about more specific capabilities of a particular plugin. Alternatively, [install Nx Console](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) to browse plugins and generators in your IDE. + +[Learn more about Nx plugins »](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) | [Browse the plugin registry »](https://nx.dev/plugin-registry?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +[Learn more about Nx on CI](https://nx.dev/ci/intro/ci-with-nx#ready-get-started-with-your-provider?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Install Nx Console + +Nx Console is an editor extension that enriches your developer experience. It lets you run tasks, generate code, and improves code autocompletion in your IDE. It is available for VSCode and IntelliJ. + +[Install Nx Console »](https://nx.dev/getting-started/editor-setup?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +## Useful links + +Learn more: + +- [Learn more about this workspace setup](https://nx.dev/nx-api/nest?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [Learn about Nx on CI](https://nx.dev/ci/intro/ci-with-nx?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [Releasing Packages with Nx release](https://nx.dev/features/manage-releases?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- [What are Nx plugins?](https://nx.dev/concepts/nx-plugins?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) + +And join the Nx community: + +- [Discord](https://go.nx.dev/community) +- [Follow us on X](https://twitter.com/nxdevtools) or [LinkedIn](https://www.linkedin.com/company/nrwl) +- [Our Youtube channel](https://www.youtube.com/@nxdevtools) +- [Our blog](https://nx.dev/blog?utm_source=nx_project&utm_medium=readme&utm_campaign=nx_projects) +- diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/config/config.js b/project/aitoearn-monorepo/apps/aitoearn-channel/config/config.js new file mode 100644 index 000000000..9a6440b7e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/config/config.js @@ -0,0 +1,222 @@ +const os = require('node:os') + +const { + REDIS_HOST, + REDIS_PORT, +} = process.env + +const { + MONGODB_HOST, + MONGODB_PORT, + MONGODB_USERNAME, + MONGODB_PASSWORD, +} = process.env + +const { + APP_ENV, + APP_NAME, + APP_DOMAIN, +} = process.env + +const { + SERVER_URL, +} = process.env + +const { + FEISHU_WEBHOOK_URL, + FEISHU_WEBHOOK_SECRET, +} = process.env + +const { + BILIBILI_CLIENT_ID, + BILIBILI_CLIENT_SECRET, + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, + KWAI_CLIENT_ID, + KWAI_CLIENT_SECRET, + PINTEREST_CLIENT_ID, + PINTEREST_CLIENT_SECRET, + PINTEREST_TEST_AUTHORIZATION, + TIKTOK_CLIENT_ID, + TIKTOK_CLIENT_SECRET, + TWITTER_CLIENT_ID, + TWITTER_CLIENT_SECRET, + FACEBOOK_CLIENT_ID, + FACEBOOK_CLIENT_SECRET, + FACEBOOK_CONFIG_ID, + THREADS_CLIENT_ID, + THREADS_CLIENT_SECRET, + INSTAGRAM_CLIENT_ID, + INSTAGRAM_CLIENT_SECRET, + LINKEDIN_CLIENT_ID, + LINKEDIN_CLIENT_SECRET, + YOUTUBE_CLIENT_ID, + YOUTUBE_CLIENT_SECRET, + WXPLAT_APP_ID, + WXPLAT_APP_SECRET, + WXPLAT_ENCODING_AES_KEY, +} = process.env + +const { + ALI_GREEN_ACCESS_KEY_ID, + ALI_GREEN_ACCESS_KEY_SECRET, +} = process.env + +const { + INTERNAL_TOKEN, +} = process.env + +module.exports = { + port: 7001, + env: 'production', + enableBadRequestDetails: true, + logger: { + console: { + enable: false, + level: 'debug', + }, + cloudWatch: { + enable: true, + region: 'ap-southeast-1', + group: `aitoearn-apps/${APP_ENV}/${APP_NAME}`, + stream: `${os.hostname()}`, + }, + feishu: { + enable: true, + url: FEISHU_WEBHOOK_URL, + secret: FEISHU_WEBHOOK_SECRET, + }, + }, + redis: { + nodes: [{ + host: REDIS_HOST, + port: Number(REDIS_PORT), + }], + options: { + redisOptions: { + db: 1, + tls: {}, + }, + }, + }, + mongodb: { + uri: `mongodb://${MONGODB_USERNAME}:${encodeURIComponent(MONGODB_PASSWORD)}@${MONGODB_HOST}:${MONGODB_PORT}/?tls=true&tlsCAFile=global-bundle.pem&retryWrites=false`, + dbName: 'aitoearn_channel', + }, + awsS3: { + region: 'ap-southeast-1', + bucketName: 'aitoearn', + endpoint: 'https://aitoearn.s3.ap-southeast-1.amazonaws.com', + }, + bilibili: { + id: BILIBILI_CLIENT_ID, + secret: BILIBILI_CLIENT_SECRET, + authBackHost: `https://${APP_DOMAIN}/api/plat/bilibili/auth/back`, + }, + google: { + id: GOOGLE_CLIENT_ID, + secret: GOOGLE_CLIENT_SECRET, + authBackHost: '', + }, + kwai: { + id: KWAI_CLIENT_ID, + secret: KWAI_CLIENT_SECRET, + authBackHost: `https://${APP_DOMAIN}/api/plat/kwai/auth/back`, + }, + pinterest: { + id: PINTEREST_CLIENT_ID, + secret: PINTEREST_CLIENT_SECRET, + authBackHost: `https://${APP_DOMAIN}/api/plat/pinterest/authWebhook`, + baseUrl: 'https://api.pinterest.com', + test_authorization: PINTEREST_TEST_AUTHORIZATION, + }, + tiktok: { + clientId: TIKTOK_CLIENT_ID, + clientSecret: TIKTOK_CLIENT_SECRET, + redirectUri: `https://${APP_DOMAIN}/api/plat/tiktok/auth/back`, + scopes: [ + 'user.info.basic', + 'user.info.profile', + 'video.upload', + 'video.publish', + ], + }, + twitter: { + clientId: TWITTER_CLIENT_ID, + clientSecret: TWITTER_CLIENT_SECRET, + redirectUri: `https://${APP_DOMAIN}/api/plat/twitter/auth/back`, + }, + oauth: { + facebook: { + clientId: FACEBOOK_CLIENT_ID, + clientSecret: FACEBOOK_CLIENT_SECRET, + configId: FACEBOOK_CONFIG_ID, + redirectUri: `https://${APP_DOMAIN}/api/plat/meta/auth/back`, + scopes: [ + 'public_profile', + 'pages_show_list', + 'pages_manage_posts', + 'pages_read_engagement', + 'pages_read_user_content', + 'pages_manage_engagement', + 'read_insights', + ], + }, + threads: { + clientId: THREADS_CLIENT_ID, + clientSecret: THREADS_CLIENT_SECRET, + redirectUri: `https://${APP_DOMAIN}/api/plat/meta/auth/back`, + scopes: [ + 'threads_basic', + 'threads_content_publish', + 'threads_read_replies', + 'threads_manage_replies', + 'threads_manage_insights', + 'threads_location_tagging', + ], + }, + instagram: { + clientId: INSTAGRAM_CLIENT_ID, + clientSecret: INSTAGRAM_CLIENT_SECRET, + redirectUri: `https://${APP_DOMAIN}/api/plat/meta/auth/back`, + scopes: [ + 'instagram_business_basic', + 'instagram_business_manage_comments', + 'instagram_business_content_publish', + ], + }, + linkedin: { + clientId: LINKEDIN_CLIENT_ID, + clientSecret: LINKEDIN_CLIENT_SECRET, + redirectUri: `https://${APP_DOMAIN}/api/plat/meta/auth/back`, + scopes: ['openid', 'profile', 'email', 'w_member_social'], + }, + }, + + wxPlat: { + id: WXPLAT_APP_ID, + secret: WXPLAT_APP_SECRET, + token: 'aitoearn', + encodingAESKey: WXPLAT_ENCODING_AES_KEY, + authBackHost: `https://${APP_DOMAIN}/platcallback`, + }, + myWxPlat: { + id: 'dev', + secret: 'f1a36f23d027c969d6c6969423d72eda', + hostUrl: `https://wxplat.${APP_DOMAIN}`, + }, + youtube: { + id: YOUTUBE_CLIENT_ID, + secret: YOUTUBE_CLIENT_SECRET, + authBackHost: `https://${APP_DOMAIN}/api/plat/youtube/auth/callback`, + }, + aliGreen: { + accessKeyId: ALI_GREEN_ACCESS_KEY_ID, + accessKeySecret: ALI_GREEN_ACCESS_KEY_SECRET, + endpoint: `green-cip.cn-beijing.aliyuncs.com`, + }, + server: { + baseUrl: SERVER_URL, + token: INTERNAL_TOKEN, + }, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/eslint.config.mjs b/project/aitoearn-monorepo/apps/aitoearn-channel/eslint.config.mjs new file mode 100644 index 000000000..e18084e7d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/eslint.config.mjs @@ -0,0 +1,9 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + rules: { + 'no-async-promise-executor': 'off', + }, + }, +) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/package.json b/project/aitoearn-monorepo/apps/aitoearn-channel/package.json new file mode 100644 index 000000000..7f1e53e45 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/package.json @@ -0,0 +1,80 @@ +{ + "name": "aitoearn-channel", + "version": "0.0.1", + "private": true, + "dependencies": { + "@alicloud/credentials": "^2.4.4", + "@alicloud/green20220302": "^2.22.1", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.10", + "@aws-sdk/client-s3": "^3.846.0", + "@aws-sdk/s3-request-presigner": "^3.846.0", + "@modelcontextprotocol/sdk": "^1.15.0", + "@nestjs/axios": "^4.0.0", + "@nestjs/bullmq": "^11.0.2", + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.2", + "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/microservices": "^11.1.2", + "@nestjs/mongoose": "^11.0.3", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.2.0", + "@rekog/mcp-nest": "^1.6.2", + "@yikart/aitoearn-queue": "workspace:*", + "@yikart/aitoearn-server-client": "workspace:*", + "@yikart/common": "workspace:*", + "@yikart/redis": "workspace:*", + "ali-oss": "^6.23.0", + "axios": "^1.9.0", + "bullmq": "^5.56.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "commander": "^14.0.0", + "dayjs": "^1.11.13", + "fast-xml-parser": "^5.2.5", + "form-data": "^4.0.4", + "googleapis": "^150.0.1", + "ioredis": "^5.6.1", + "lodash": "^4.17.21", + "mime-types": "^3.0.1", + "moment": "^2.30.1", + "mongodb": "^6.20.0", + "mongoose": "^8.15.0", + "needle": "^3.3.1", + "pnpm": "^10.12.4", + "qs": "^6.14.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "uuid": "^11.1.0", + "xml2js": "^0.6.2", + "zod": "^4.1.9" + }, + "devDependencies": { + "@antfu/eslint-config": "^4.16.2", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.17.20", + "@types/multer": "^2.0.0", + "@types/node": "^22.10.7", + "@types/supertest": "^6.0.2", + "@types/xml2js": "^0.4.14", + "eslint": "^9.18.0", + "eslint-plugin-format": "^1.0.1", + "globals": "^16.0.0", + "jest": "^29.7.0", + "lint-staged": "^16.1.2", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3" + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/project.json b/project/aitoearn-monorepo/apps/aitoearn-channel/project.json new file mode 100644 index 000000000..5a1e56782 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/project.json @@ -0,0 +1,76 @@ +{ + "name": "aitoearn-channel", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/aitoearn-channel/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/aitoearn-channel", + "tsConfig": "apps/aitoearn-channel/tsconfig.app.json", + "packageJson": "apps/aitoearn-channel/package.json", + "main": "apps/aitoearn-channel/src/main.ts", + "assets": ["apps/aitoearn-channel/*.md"], + "generatePackageJson": true, + "clean": true + } + }, + "prune-lockfile": { + "dependsOn": ["build"], + "cache": true, + "executor": "@nx/js:prune-lockfile", + "outputs": [ + "{workspaceRoot}/dist/apps/aitoearn-channel/package.json", + "{workspaceRoot}/dist/apps/aitoearn-channel/pnpm-lock.yaml" + ], + "options": { + "buildTarget": "build" + } + }, + "serve": { + "continuous": true, + "executor": "@nx/js:node", + "defaultConfiguration": "local", + "dependsOn": ["build"], + "options": { + "buildTarget": "aitoearn-channel:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "local": { + "buildTarget": "aitoearn-channel:build:development", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-channel/config/local.config.js" + ] + }, + "dev": { + "buildTarget": "aitoearn-channel:build:development", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-channel/config/dev.config.js" + ] + }, + "prod": { + "buildTarget": "aitoearn-channel:build:production", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-channel/config/prod.config.js" + ] + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "fix": true + } + }, + "docker-context": {}, + "docker-build": {} + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.controller.ts new file mode 100644 index 000000000..2a40168c5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Get, Logger, Post } from '@nestjs/common' +import { Ctx, NatsContext } from '@nestjs/microservices' +import { AppService } from './app.service' + +@Controller() +export class AppController { + private readonly logger = new Logger(AppController.name) + + constructor(private readonly appService: AppService) {} + + @Get() + getSysInfo() { + return { + // ip: + } + } + + // @NatsMessagePattern('chanel.ping') + @Post('chanel/ping') + pong(@Body() data: number[], @Ctx() context: NatsContext) { + this.logger.debug(`Subject: ${context.getSubject()}`) + this.logger.debug(`Data:`, data) + return { + message: 'Pong', + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.module.ts new file mode 100644 index 000000000..96c476cbe --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.module.ts @@ -0,0 +1,29 @@ +import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { ScheduleModule } from '@nestjs/schedule' +import { AitoearnQueueModule } from '@yikart/aitoearn-queue' +import { AitoearnServerClientModule } from '@yikart/aitoearn-server-client' +import { AppController } from './app.controller' +import { AppService } from './app.service' +import { config } from './config' +import { CoreModule } from './core/core.module' +import { DbMongoModule } from './libs/database' + +@Module({ + imports: [ + AitoearnServerClientModule.forRoot(config.server), + EventEmitterModule.forRoot(), + DbMongoModule, + CoreModule, + ScheduleModule.forRoot(), + AitoearnQueueModule.forRoot({ + redis: config.redis, + prefix: '{bull}', + }), + ], + controllers: [AppController], + providers: [ + AppService, + ], +}) +export class AppModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.service.ts new file mode 100644 index 000000000..bc13055dd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common' + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!' + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/all.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/all.enum.ts new file mode 100644 index 000000000..2782e776d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/all.enum.ts @@ -0,0 +1,29 @@ +/* + * @Author: nevin + * @Date: 2022-08-02 16:18:04 + * @LastEditTime: 2025-01-15 14:37:02 + * @LastEditors: nevin + * @Description: + */ +export enum HttpTags { + DOC = 'doc', +} + +// 性别 +export enum GenderEnum { + MALE = 1, // 男 + FEMALE = 2, // 女 +} + +// 开关 +export enum ONOFF { + ON = 1, // 开 + OFF = 0, // 关 +} + +// 0 待审核 1 审核通过 -1 审核不通过 +export enum CheckStatus { + PENDING = 0, + PASS = 1, + FAIL = -1, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/area.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/area.enum.ts new file mode 100644 index 000000000..c3edd45c8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/area.enum.ts @@ -0,0 +1,12 @@ +/* + * @Author: nevin + * @Date: 2022-05-11 14:44:41 + * @LastEditTime: 2024-06-17 19:28:45 + * @LastEditors: nevin + * @Description: + */ +export enum AreaTypes { + Province = 1, + City = 2, + County = 3, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/exception-code.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/exception-code.enum.ts new file mode 100644 index 000000000..cffe83134 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/exception-code.enum.ts @@ -0,0 +1,6 @@ +export enum ExceptionCode { + Success = 0, + Failed = 1, + UserNotFound = 10001, + NatsMessageError = 10002, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/index.ts new file mode 100644 index 000000000..dfb8c1f24 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/enums/index.ts @@ -0,0 +1,2 @@ +export * from './all.enum' +export * from './area.enum' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/comment.ts new file mode 100644 index 000000000..e4670e2dc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/comment.ts @@ -0,0 +1,6 @@ +export interface NatsRes { + code: number + message: string + data?: T + timestamp: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/dto/table.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/dto/table.dto.ts new file mode 100644 index 000000000..018aecbe8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/dto/table.dto.ts @@ -0,0 +1,33 @@ +/* + * @Author: nevin + * @Date: 2022-03-17 18:14:52 + * @LastEditors: nevin + * @LastEditTime: 2024-10-10 15:45:59 + * @Description: 表单数据 + */ + +import { ApiProperty } from '@nestjs/swagger' +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const tableSchema = z.object({ + pageNo: z.number().int({ message: '页码必须是数值' }).optional().default(1), + pageSize: z + .number() + .int({ message: '每页个数必须是数值' }) + .optional() + .default(10), +}) + +export class TableDto extends createZodDto(tableSchema) {} + +export class TableResDto { + @ApiProperty({ title: '页码', description: '页码' }) + readonly pageNo: number = 1 + + @ApiProperty({ title: '页数', description: '页数' }) + readonly pageSize: number = 10 + + @ApiProperty({ title: '总数', description: '总数' }) + readonly count: number = 0 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/all.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/all.enum.ts new file mode 100644 index 000000000..2782e776d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/all.enum.ts @@ -0,0 +1,29 @@ +/* + * @Author: nevin + * @Date: 2022-08-02 16:18:04 + * @LastEditTime: 2025-01-15 14:37:02 + * @LastEditors: nevin + * @Description: + */ +export enum HttpTags { + DOC = 'doc', +} + +// 性别 +export enum GenderEnum { + MALE = 1, // 男 + FEMALE = 2, // 女 +} + +// 开关 +export enum ONOFF { + ON = 1, // 开 + OFF = 0, // 关 +} + +// 0 待审核 1 审核通过 -1 审核不通过 +export enum CheckStatus { + PENDING = 0, + PASS = 1, + FAIL = -1, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/area.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/area.enum.ts new file mode 100644 index 000000000..c3edd45c8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/global/enum/area.enum.ts @@ -0,0 +1,12 @@ +/* + * @Author: nevin + * @Date: 2022-05-11 14:44:41 + * @LastEditTime: 2024-06-17 19:28:45 + * @LastEditors: nevin + * @Description: + */ +export enum AreaTypes { + Province = 1, + City = 2, + County = 3, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/guards/skKeyAuth.guard.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/guards/skKeyAuth.guard.ts new file mode 100644 index 000000000..73372e6e6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/guards/skKeyAuth.guard.ts @@ -0,0 +1,45 @@ +import { + CanActivate, + createParamDecorator, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { SkKeyService } from '../../core/skKey/skKey.service' + +export const GetSkKey = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest() + return req['skKey'] + }, +) + +@Injectable() +export class SkKeyAuthGuard implements CanActivate { + private readonly logger = new Logger(SkKeyAuthGuard.name) + constructor( + private reflector: Reflector, + private readonly skKeyService: SkKeyService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest() + // 获取令牌sk + const key = request.headers['sk-key'] || request.query['sk-key'] + + try { + const keyInfo = await this.skKeyService.getInfo(key) + if (!keyInfo) { + throw new UnauthorizedException('令牌验证失败') + } + request['skKey'] = keyInfo + } + catch (error) { + this.logger.error(error) + throw new UnauthorizedException('令牌验证失败') + } + return true + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/index.ts new file mode 100644 index 000000000..fb8ea894c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/index.ts @@ -0,0 +1,4 @@ +export * from './enums' +export * from './interceptors' +export * from './interfaces' +export * from './utils' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/index.ts new file mode 100644 index 000000000..75b1efd52 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/index.ts @@ -0,0 +1 @@ +export * from './xml.interceptor' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/xml.interceptor.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/xml.interceptor.ts new file mode 100644 index 000000000..a2d76aed7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interceptors/xml.interceptor.ts @@ -0,0 +1,32 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common' +import { Observable } from 'rxjs' +import { switchMap } from 'rxjs/operators' +import * as xml2js from 'xml2js' + +@Injectable() +export class XmlParseInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest() + + return new Observable((subscriber) => { + xml2js.parseString( + request.body, + { explicitArray: false }, + (err, result) => { + if (err) + return subscriber.error(err) + request.body = result.xml || {} + subscriber.next(request) + subscriber.complete() + }, + ) + }).pipe( + switchMap(() => next.handle()), // 正确串联后续处理 + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/index.ts new file mode 100644 index 000000000..b6cba9106 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/index.ts @@ -0,0 +1 @@ +export * from './response.interface' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/response.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/response.interface.ts new file mode 100644 index 000000000..9028b7593 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/interfaces/response.interface.ts @@ -0,0 +1,6 @@ +export interface CommonResponse { + data?: T + code: number + message: string + timestamp?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/file.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/file.util.ts new file mode 100644 index 000000000..8ad56c571 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/file.util.ts @@ -0,0 +1,257 @@ +import * as fs from 'node:fs' +import path from 'node:path' +import axios, { AxiosResponse } from 'axios' +import { v4 as uuidv4 } from 'uuid' + +enum Type { + IMAGE = '图片', + TXT = '文档', + MUSIC = '音乐', + VIDEO = '视频', + OTHER = '其他', +} + +export function getFileType(extName: string) { + const documents = 'txt doc pdf ppt pps xlsx xls docx' + const music = 'mp3 wav wma mpa ram ra aac aif m4a' + const video = 'avi mpg mpe mpeg asf wmv mov qt rm mp4 flv m4v webm ogv ogg' + const image + = 'bmp dib pcp dif wmf gif jpg tif eps psd cdr iff tga pcd mpt png jpeg' + if (image.includes(extName)) + return Type.IMAGE + + if (documents.includes(extName)) + return Type.TXT + + if (music.includes(extName)) + return Type.MUSIC + + if (video.includes(extName)) + return Type.VIDEO + + return Type.OTHER +} + +export function getName(fileName: string) { + if (fileName.includes('.')) + return fileName.split('.')[0] + + return fileName +} + +export function getExtname(fileName: string) { + return path.extname(fileName).replace('.', '') +} + +export function getSize(bytes: number, decimals = 2) { + if (bytes === 0) + return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` +} + +/** + * nodejs存储文件到本地 + * @param base64String + * @param path + * @param fileName + * @returns + */ +export function saveFile(base64String: string, path: string, fileName: string) { + // 文件不存在则创建文件 + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }) + } + + return new Promise((resolve, reject) => { + fs.writeFile(path + fileName, base64String, 'base64', (err) => { + if (err) { + reject(err) + } + else { + resolve(true) + } + }) + }) +} + +/** + * 文件URL转Blob + * @param url + * @returns + */ + +export async function urlToBlob(url: string): Promise { + const response = await axios.get(url, { + responseType: 'arraybuffer', + }) + + return new Blob([response.data], { type: response.headers['content-type'] }) +} + +/** + * 将远程文件 URL 转换为 Base64 + * @param url 文件的 URL 地址 + * @returns Base64 字符串 + */ +export async function fileUrlToBase64(url: string): Promise { + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + }) + + // 将响应数据转为 Buffer 并转换为 Base64 + return Buffer.from(response.data).toString('base64') + } + catch (error) { + throw new Error(`将URL转换为Base64错误: ${error}`) + } +} + +/** + * 将远程文件 URL 转换为 Blob + * @param url 文件的 URL 地址 + * @returns Blob + */ +export async function fileUrlToBlob(url: string): Promise<{ blob: Blob, fileName: string }> { + try { + const response = await axios.get(url, { + responseType: 'arraybuffer', + }) + + const contentType + = response.headers['content-type'] || 'application/octet-stream' + // 构建 Blob + const blob = new Blob([response.data], { type: contentType }) + return { + blob, + fileName: url.split('/').pop() || '', + } + } + catch (error) { + throw new Error(`将URL转换为Blob错误: ${error}`) + } +} + +// 根据文件URL获取文件类型 +export function getFileTypeFromUrl(url: string, newName = false): string { + const urlParts = url.split('.') + const extension = urlParts[urlParts.length - 1] + return newName ? `${uuidv4()}.${extension}` : extension +} + +/** + * 分片下载远程视频文件 + * @param url 视频地址 + * @param upFn + * @param overFn + * @param chunkSize 每个分片大小(字节) + */ +export async function streamDownloadAndUpload( + url: string, + uploadHandler: (upData: Buffer, partNumber: number) => Promise, + postUploadHandler: (partCount: number) => Promise, + chunkSize = 1024 * 1024 * 5, +) { + try { + let partNumber = 1 + + // 获取总大小 + const headResponse: AxiosResponse = await axios.head(url) + const totalSize = Number.parseInt( + headResponse.headers['content-length'], + 10, + ) + + for (let start = 0; start < totalSize; start += chunkSize) { + const end = Math.min(start + chunkSize - 1, totalSize - 1) + const range = `bytes=${start}-${end}` + const rangeRes: AxiosResponse = await axios.get(url, { + responseType: 'stream', + headers: { + Range: range, + }, + }) + const buffer = await incomingMessageToBuffer(rangeRes.data) + await uploadHandler(buffer, partNumber) + partNumber++ + } + await postUploadHandler(partNumber) + } + catch (e) { + throw new Error(e) + } +} + +/** + * 获取远程文件大小 + * @param url 文件的 URL 地址 + * @returns + */ +export async function getFileSizeFromUrl(url: string): Promise { + try { + const headResponse: AxiosResponse = await axios.head(url) + const contentLength = Number.parseInt( + headResponse.headers['content-length'], + 10, + ) + return contentLength + } + catch (error) { + throw new Error(`获取文件大小错误: ${error}`) + } +} + +/** + * 分片下载文件 + * @param url 远程文件的 URL 地址 + * @param range 分片范围 [start, end] + * @returns + */ +export async function chunkedDownloadFile( + url: string, + range: [number, number], +): Promise> { + try { + const chunk = await axios.get(url, { + responseType: 'arraybuffer', + headers: { + Range: `bytes=${range[0]}-${range[1]}`, + }, + }) + return Buffer.from(chunk.data) + } + catch (error) { + throw new Error(`Failed to download file chunk from ${url}: ${error}`) + } +} + +export async function getRemoteFileSize(url: string): Promise { + try { + const response = await axios.head(url) + if (!response.headers['content-length']) { + throw new Error('Content-Length header is missing') + } + const contentLength = Number.parseInt(response.headers['content-length'], 10) + return contentLength + } + catch (error) { + throw new Error(`Failed to get remote file metadata: ${error}, URL: ${url}`) + } +} + +// 将可读流转换为Buffer +function incomingMessageToBuffer(stream: any): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + stream.on('data', (chunk: Buffer) => chunks.push(chunk)) + stream.on('end', () => resolve(Buffer.concat(chunks))) + stream.on('error', reject) + }) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/get-code-message.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/get-code-message.util.ts new file mode 100644 index 000000000..0e759978e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/get-code-message.util.ts @@ -0,0 +1,8 @@ +import { ExceptionCode } from '../enums/exception-code.enum' + +const codeMessageMap: Partial> = { + [ExceptionCode.Success]: '请求成功', +} +export function getCodeMessage(code: ExceptionCode) { + return codeMessageMap[code] || '未知错误' +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/index.ts new file mode 100644 index 000000000..18b3ae78a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/index.ts @@ -0,0 +1,39 @@ +/* + * @Author: nevin + * @Date: 2024-07-04 15:15:28 + * @LastEditTime: 2024-11-26 18:39:44 + * @LastEditors: nevin + * @Description: 工具 + */ + +/** + * 封装一个非阻塞的sleep函数,返回一个Promise对象。 + * @param {number} milliseconds - 指定延迟的毫秒数。 + * @returns {Promise} - 一个Promise,将在指定延迟后resolve。 + */ +export function sleep(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds) + }) +} + +// 封装一个获取随机的任意个数字或字母的字符串的函数 +export function getRandomString(length: number, onlyNum = false): string { + const chars = onlyNum + ? '1234567890' + : 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let result = '' + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result +} + +// 导出所有工具函数 +export * from './file.util' +export * from './ip.util' +export * from './is.util' +export * from './list2tree.util' +export * from './map' +export * from './password.util' +export * from './time.util' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/ip.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/ip.util.ts new file mode 100644 index 000000000..3cc488ddd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/ip.util.ts @@ -0,0 +1,65 @@ +import type { IncomingMessage } from 'node:http' +/** + * @module utils/ip + * @description IP utility functions + */ +import axios from 'axios' + +/* 判断IP是不是内网 */ +function isLAN(ip: string) { + ip.toLowerCase() + if (ip === 'localhost') + return true + let a_ip = 0 + if (ip === '') + return false + const aNum = ip.split('.') + if (aNum.length !== 4) + return false + a_ip += Number.parseInt(aNum[0]) << 24 + a_ip += Number.parseInt(aNum[1]) << 16 + a_ip += Number.parseInt(aNum[2]) << 8 + a_ip += Number.parseInt(aNum[3]) << 0 + a_ip = (a_ip >> 16) & 0xFFFF + return ( + a_ip >> 8 === 0x7F + || a_ip >> 8 === 0xA + || a_ip === 0xC0A8 + || (a_ip >= 0xAC10 && a_ip <= 0xAC1F) + ) +} + +export function getIp(request: IncomingMessage) { + const req = request as any + + let ip: string + = request.headers['x-forwarded-for'] + || request.headers['X-Forwarded-For'] + || request.headers['X-Real-IP'] + || request.headers['x-real-ip'] + || req?.ip + || req?.raw?.connection?.remoteAddress + || req?.raw?.socket?.remoteAddress + || undefined + if (ip && ip.split(',').length > 0) + ip = ip.split(',')[0] + + return ip +} + +export async function getIpAddress(ip: string) { + if (isLAN(ip)) + return '内网IP' + try { + let { data } = await axios.get( + `https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, + { responseType: 'arraybuffer' }, + ) + data = new TextDecoder('gbk').decode(data) + data = JSON.parse(data) + return data.addr.trim().split(' ').at(0) + } + catch { + return '第三方接口请求失败' + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/is.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/is.util.ts new file mode 100644 index 000000000..a55858713 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/is.util.ts @@ -0,0 +1,4 @@ +/** 判断是否外链 */ +export function isExternal(path: string): boolean { + return /^(?:https?:|mailto:|tel:)/.test(path) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/list2tree.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/list2tree.util.ts new file mode 100644 index 000000000..68b9fd2ef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/list2tree.util.ts @@ -0,0 +1,83 @@ +export type TreeNode = T & { + id: number + parentId: number + children?: TreeNode[] +} + +export type ListNode = T & { + id: number + parentId: number +} + +export function list2Tree( + items: T, + parentId: number | null = null, +): TreeNode[] { + return items + .filter(item => item.parentId === parentId) + .map((item) => { + const children = list2Tree(items, item.id) + return { + ...item, + ...(children.length ? { children } : null), + } + }) +} + +/** + * 过滤树,返回列表数据 + * @param treeData + * @param key 用于过滤的字段 + * @param value 用于过滤的值 + */ +export function filterTree2List(treeData, key, value) { + const filterChildrenTree = (resTree, treeItem) => { + if (treeItem[key].includes(value)) { + resTree.push(treeItem) + return resTree + } + if (Array.isArray(treeItem.children)) { + const children = treeItem.children.reduce(filterChildrenTree, []) + + const data = { ...treeItem, children } + + if (children.length) + resTree.push({ ...data }) + } + return resTree + } + return treeData.reduce(filterChildrenTree, []) +} + +/** + * 过滤树,并保留原有的结构 + * @param treeData + * @param predicate + */ +export function filterTree( + treeData: TreeNode[], + predicate: (data: T) => boolean, +): TreeNode[] { + function filter(treeData: TreeNode[]): TreeNode[] { + if (!treeData?.length) + return treeData + + return treeData.filter((data) => { + if (!predicate(data)) + return false + + data.children = filter((data as any).children) + return true + }) + } + + return filter(treeData) || [] +} + +export function deleteEmptyChildren(arr: any) { + arr?.forEach((node) => { + if (node.children?.length === 0) + delete node.children + else deleteEmptyChildren(node.children) + }) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/map.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/map.ts new file mode 100644 index 000000000..ee220c013 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/map.ts @@ -0,0 +1,34 @@ +/* + * @Author: nevin + * @Date: 2024-07-29 11:14:20 + * @LastEditTime: 2024-07-29 11:17:33 + * @LastEditors: nevin + * @Description: 地图 + */ +export function isWithinMeters( + locus1: number[], + locus2: number[], + distanceInMeters: number, // 千米 +) { + const [lat1, lon1] = locus1 + const [lat2, lon2] = locus2 + + const R = 6371 // 地球平均半径,单位为公里 + const dLat = deg2rad(lat2 - lat1) + const dLon = deg2rad(lon2 - lon1) + const a + = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(lat1)) + * Math.cos(deg2rad(lat2)) + * Math.sin(dLon / 2) + * Math.sin(dLon / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + const distance = R * c // 距离,单位为公里 + + return distance <= distanceInMeters // 判断距离是否小于等于500米 +} + +// 辅助函数,将角度转换为弧度 +function deg2rad(deg: number) { + return deg * (Math.PI / 180) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/password.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/password.util.ts new file mode 100644 index 000000000..3e5585df2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/password.util.ts @@ -0,0 +1,59 @@ +/* + * @Author: nevin + * @Date: 2022-01-21 09:50:47 + * @LastEditors: nevin + * @LastEditTime: 2024-07-08 15:38:55 + * @Description: 认证模块-加密工具 + */ +import * as crypto from 'node:crypto' + +export interface Password { + password: string // 密码 + salt: string // 盐 +} + +/** + * 生成随机盐 + */ +function makeSalt(): string { + return crypto.randomBytes(3).toString('base64') +} + +/** + * Encrypt password + * @param password 密码 + * @param salt 密码盐 + * @returns { + * password, // 加密的密码 + * salt + * } + */ +export function encryptPassword(password: string, salt?: string): Password { + salt = salt || makeSalt() + + // 10000 代表迭代次数 16代表长度 + password = crypto + .pbkdf2Sync(password, Buffer.from(salt, 'base64'), 10000, 16, 'sha1') + .toString('base64') + return { + password, + salt, + } +} + +/** + * 校验用户信息 + * @param userPassword 用户密码 + * @param userSalt 盐值 + * @param password 密码 + * @returns + */ +export function validatePassWord( + userPassword: string, + userSalt: string, + password: string, +): boolean { + // 通过密码盐,加密传参,再与数据库里的比较,判断是否相等 + const res = encryptPassword(password, userSalt) + return userPassword === res.password +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/str.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/str.util.ts new file mode 100644 index 000000000..32d9a3580 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/str.util.ts @@ -0,0 +1,37 @@ +import * as crypto from 'node:crypto' +import { XMLParser } from 'fast-xml-parser' + +class StrUtil { + private xMlParser: XMLParser + constructor() { + // this.xMlParser = new XMLParser({ + // ignoreAttributes: false, + // attributeNamePrefix: '@_', + // isArray: () => false, // 自定义数组判断 + // trimValues: true, + // parseTagValue: true, + // parseAttributeValue: true, + // }); + + this.xMlParser = new XMLParser() + } + + xmlToObject(xml: string): any { + return this.xMlParser.parse(xml).xml + } + + generateComplexKey(length = 32) { + const chars + = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const randomBytes = crypto.randomBytes(length) + let result = '' + + for (let i = 0; i < length; i++) { + const randomIndex = randomBytes[i] % chars.length + result += chars[randomIndex] + } + + return `sk-${result}` + } +} +export const strUtil = new StrUtil() diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/time.util.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/time.util.ts new file mode 100644 index 000000000..fafb65b18 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/common/utils/time.util.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2024-07-22 17:57:25 + * @LastEditTime: 2024-07-22 17:59:08 + * @LastEditors: nevin + * @Description: + */ +// 获取今天的0点(午夜)时间 +export function getTodayMidnight(): Date { + const todayMidnight = new Date() + todayMidnight.setHours(0, 0, 0, 0) + + return todayMidnight +} + +// 获取今天的24点(实际上是明天的0点) +export function getTodayEnd(): Date { + const todayEnd = new Date() + todayEnd.setDate(todayEnd.getDate() + 1) + todayEnd.setHours(0, 0, 0, 0) + return todayEnd +} + +// 获取当前的秒级时间戳 +export function getCurrentTimestamp(): number { + return Math.floor(new Date().getTime() / 1000) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/config.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/config.ts new file mode 100644 index 000000000..9530035eb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/config.ts @@ -0,0 +1,122 @@ +import { aitoearnServerClientConfigSchema } from '@yikart/aitoearn-server-client' +import { baseConfig, createZodDto, selectConfig } from '@yikart/common' +import { redisConfigSchema } from '@yikart/redis' +import { z } from 'zod' +import { s3ConfigSchema } from './libs/aws-s3/s3.config' + +// MongoDB配置 +const mongoConfigSchema = z.object({ + uri: z.string().default(''), + dbName: z.string().default(''), +}) + +const kwaiSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + authBackHost: z.string().default(''), +}) + +// bilibili配置 +const BilibiliSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + authBackHost: z.string().default(''), +}) + +// google配置 +const GoogleSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + authBackHost: z.string().default(''), +}) + +// tiktok配置 +const PinterestSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + baseUrl: z.string().default(''), + authBackHost: z.string().default(''), + test_authorization: z.string().default(''), +}) + +// tiktok配置 +const TiktokSchema = z.object({ + clientId: z.string().default(''), + clientSecret: z.string().default(''), + redirectUri: z.string().default(''), + scopes: z.array(z.string()).default([]), +}) + +// twitter配置 +const TwitterSchema = z.object({ + clientId: z.string().default(''), + clientSecret: z.string().default(''), + redirectUri: z.string().default(''), +}) + +// wxPlat配置 +const WxPlatSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + token: z.string().default(''), + encodingAESKey: z.string().default(''), + authBackHost: z.string().default(''), +}) + +// 自建微信三方平台服务 +const MyWxPlatSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + hostUrl: z.string().default(''), +}) + +// youtube配置 +const YoutubeSchema = z.object({ + id: z.string().default(''), + secret: z.string().default(''), + authBackHost: z.string().default(''), +}) + +const OAuth2ConfigSchema = z.object({ + clientId: z.string().default(''), + clientSecret: z.string().default(''), + configId: z.string().default(''), + redirectUri: z.string().default(''), + scopes: z.array(z.string()).default([]), +}) + +const MetaOAuth2ConfigSchema = z.object({ + facebook: OAuth2ConfigSchema, + threads: OAuth2ConfigSchema, + instagram: OAuth2ConfigSchema, + linkedin: OAuth2ConfigSchema, +}) + +const AliGreenConfigSchema = z.object({ + accessKeyId: z.string().default(''), + accessKeySecret: z.string().default(''), + endpoint: z.string().default(''), +}) + +export const configSchema = z.object({ + ...baseConfig.shape, + server: aitoearnServerClientConfigSchema, + redis: redisConfigSchema, + mongodb: mongoConfigSchema, + awsS3: s3ConfigSchema, + bilibili: BilibiliSchema, + kwai: kwaiSchema, + google: GoogleSchema, + pinterest: PinterestSchema, + tiktok: TiktokSchema, + twitter: TwitterSchema, + wxPlat: WxPlatSchema, + myWxPlat: MyWxPlatSchema, + youtube: YoutubeSchema, + oauth: MetaOAuth2ConfigSchema, + aliGreen: AliGreenConfigSchema, +}) + +export class AppConfig extends createZodDto(configSchema) {} + +export const config = selectConfig(AppConfig) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.module.ts new file mode 100644 index 000000000..5288288bd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { Account, AccountSchema } from '../../libs/database/schema/account.schema' +import { AccountService } from './account.service' +import { PublishRecordService } from './publishRecord.service' + +@Global() +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Account.name, schema: AccountSchema }]), + ], + providers: [AccountService, PublishRecordService], + exports: [AccountService, PublishRecordService], +}) +export class AccountModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.service.ts new file mode 100644 index 000000000..918400041 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/account.service.ts @@ -0,0 +1,164 @@ +import { Injectable, Logger } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { InjectModel } from '@nestjs/mongoose' +import { AccountStatus, AccountType, AitoearnServerClientService, NewAccount } from '@yikart/aitoearn-server-client' +import { Model, UpdateQuery } from 'mongoose' +import { TableDto } from '../../common/global/dto/table.dto' +import { Account } from '../../libs/database/schema/account.schema' + +@Injectable() +export class AccountService { + private readonly logger = new Logger(AccountService.name) + constructor( + @InjectModel(Account.name) + private readonly accountModel: Model, + private readonly serverClient: AitoearnServerClientService, + private eventEmitter: EventEmitter2, + ) {} + + /** + * 创建账户 + * 如果已存在,则更新账户信息 + * @returns + */ + async createAccount( + userId: string, + account: { + type: AccountType + uid: string + }, + data: NewAccount, + ) { + this.logger.log({ + msg: '-------- ***** --------', + data: { + account, + data, + }, + }) + // 查询是否已存在相同账户 + const existAccount = await this.accountModel.findOne({ + type: account.type, + uid: account.uid, + }) + this.logger.log({ + msg: '-------- 00000 --------', + data: existAccount, + }) + let newOrUpdatedAccount: Account | null + + const newData: UpdateQuery = { ...data, ...account, userId, loginTime: new Date() } + if (existAccount) { + // 已存在,执行更新 + newOrUpdatedAccount = await this.accountModel.findOneAndUpdate( + { type: account.type, uid: account.uid }, + newData, + ) + + this.logger.log({ + msg: '-------- 111111 --------', + data: newOrUpdatedAccount, + }) + } + else { + // 不存在,创建新账户 + newData['_id'] = `${account.type}_${account.uid}` + newOrUpdatedAccount = await this.accountModel.create(newData) + + this.logger.log({ + msg: '-------- 222222 --------', + data: newOrUpdatedAccount, + }) + } + + try { + const ret = await this.serverClient.account.createAccount(newData) + this.logger.log({ + msg: '-------- 333333 --------', + data: ret, + }) + // 触发账户创建或更新事件 + // this.eventEmitter.emit(`account.create.${newOrUpdatedAccount?.type}`, newOrUpdatedAccount?.id) + } + catch (error) { + this.logger.error(error) + return null + } + this.logger.log({ + msg: '-------- 44444 --------', + data: newOrUpdatedAccount, + }) + return newOrUpdatedAccount + } + + /** + * 更新账户 + * @returns + */ + async upAccount(accountId: string, data: NewAccount) { + const res = await this.accountModel.updateOne({ _id: accountId }, data) + + try { + await this.serverClient.account.updateAccountInfo( + accountId, + data, + ) + } + catch (error) { + this.logger.error(error) + return null + } + + return res + } + + /** + * 获取信息 + * @returns + */ + async getAccountInfo(accountId: string) { + return this.accountModel.findById(accountId) + } + + /** + * 获取列表 + * @param page + * @param filter + * @returns + */ + async getAccountList(page: TableDto, filter: { type?: AccountType } = {}) { + const list = await this.accountModel.find( + { + ...filter, + }, + {}, + { + skip: (page.pageNo! - 1) * page.pageSize, + limit: page.pageSize, + }, + ) + + return { + list, + total: await this.accountModel.countDocuments(filter), + } + } + + async getUserAccountList(userId: string) { + return this.accountModel.find( + { + userId, + }, + ) + } + + /** + * 更新频道在线状态 + * @param id + * @param status + * @returns + */ + async updateAccountStatus(id: string, status: AccountStatus) { + return await this.accountModel.updateOne({ _id: id }, { status }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/publishRecord.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/publishRecord.service.ts new file mode 100644 index 000000000..a850fcc36 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/account/publishRecord.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AitoearnServerClientService, PublishRecord, PublishStatus } from '@yikart/aitoearn-server-client' + +@Injectable() +export class PublishRecordService { + constructor( + private readonly serverClient: AitoearnServerClientService, + ) {} + + // 创建 + @OnEvent('publishRecord.create', { async: true }) + async createPublishRecord(data: Partial) { + return this.serverClient.publishing.createPublishRecord(data) + } + + // 获取发布记录信息 + async getPublishRecordInfo(id: string) { + return this.serverClient.publishing.getPublishRecordInfo(id) + } + + async getPublishRecordByDataId(dataId: string, uid: string) { + return this.serverClient.publishing.getPublishRecordByDataId(dataId, uid) + } + + // 完成发布记录 + async donePublishRecord( + filter: { dataId: string, uid: string }, + data: { + workLink?: string + dataOption?: any + }, + ) { + const res = await this.serverClient.publishing.completePublishTask(filter, data) + return res + } + + async updatePublishRecordStatus(id: string, status: PublishStatus, errorMsg?: string) { + return this.serverClient.publishing.updatePublishRecordStatus(id, status, errorMsg) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.controller.ts new file mode 100644 index 000000000..c4f0a1612 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { AliGreenService } from './ali-green.service' + +@Controller() +export class AliGreenController { + constructor( + private readonly aliGreenService: AliGreenService, + ) {} + + // 文本审核 限制在2000字以内 + // @NatsMessagePattern('aliGreen.textGreen') + @Post('aliGreen/textGreen') + textGreen(@Body() data: { content: string }) { + return this.aliGreenService.textGreen(data.content) + } + + // 图片审核 限制频率 qps为100 每张图片大小限制为20M以内 + // @NatsMessagePattern('aliGreen.imgGreen') + @Post('aliGreen/imgGreen') + imgGreen(@Body() data: { imageUrl: string }) { + return this.aliGreenService.imgGreen(data.imageUrl) + } + + // 视频审核 限制视频大小 为500M以内 格式为mp4 flv + // @NatsMessagePattern('aliGreen.videoGreen') + @Post('aliGreen/videoGreen') + videoGreen(@Body() data: { url: string }) { + return this.aliGreenService.videoGreen(data.url) + } + + // @NatsMessagePattern('aliGreen.getVideoResult') + @Post('aliGreen/getVideoResult') + getVideoResult(@Body() data: { taskId: string }) { + return this.aliGreenService.getVideoResult(data.taskId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.module.ts new file mode 100644 index 000000000..34f84a0e2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { AliGreenApiModule } from '../../libs/ali-green/ali-green-api.module' +import { AliGreenController } from './ali-green.controller' +import { AliGreenService } from './ali-green.service' + +@Module({ + imports: [ + AliGreenApiModule, + ], + controllers: [AliGreenController], + providers: [AliGreenService], + exports: [AliGreenService], +}) +export class AliGreenModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.service.ts new file mode 100644 index 000000000..f86a647e7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/ali-green.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common' +import { AliGreenApiService } from '../../libs/ali-green/ali-green-api.service' + +@Injectable() +export class AliGreenService { + constructor( + private readonly aliGreenApiService: AliGreenApiService, + ) {} + + async textGreen(content: string) { + const result = await this.aliGreenApiService.textGreen(content) + return result?.body?.data || {} + } + + async imgGreen(imageUrl: string) { + const result = await this.aliGreenApiService.imgGreen(imageUrl) + return result?.body?.data || {} + } + + async videoGreen(url: string) { + const result = await this.aliGreenApiService.videoGreen(url) + return result?.body?.data || {} + } + + async getVideoResult(taskId: string) { + const result = await this.aliGreenApiService.getVideoResult(taskId) + return result?.body?.data || {} + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/dto/ali-green.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/dto/ali-green.dto.ts new file mode 100644 index 000000000..1603fca06 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/ali-green/dto/ali-green.dto.ts @@ -0,0 +1,58 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' +import { Country, Currency, SourceType } from '../../../libs/pinterest/common' + +const CreateAccountBodySchema = z.object({ + country: z.enum(Country).optional(), + currency: z.enum(Currency).optional(), + name: z.string().optional(), + owner_user_id: z.string().optional(), +}) + +export class CreateAccountBodyDto extends createZodDto(CreateAccountBodySchema) { } + +const CreateBoardBodySchema = z.object({ + name: z.string({ message: '名称' }), + accountId: z.string().optional(), +}) + +export class CreateBoardBodyDto extends createZodDto(CreateBoardBodySchema) { } + +const MediaSourceSchema = z.object({ + source_type: z.enum(SourceType, { message: '媒体类型' }), + cover_image_url: z.string().optional(), + url: z.string().optional(), +}) + +export class MediaSource extends createZodDto(MediaSourceSchema) { } + +const CreatePinBodyItemSchema = z.object({ + url: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + link: z.string().optional(), +}) + +export class CreatePinBodyItemDto extends createZodDto(CreatePinBodyItemSchema) { } + +const CreatePinBodySchema = z.object({ + board_id: z.string({ message: '此 Pin 所属的板块。' }), + accountId: z.string().optional(), + link: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + dominant_color: z.string().optional(), + alt_text: z.string().optional(), + media_source: z.any().optional(), // 使用z.any()因为MediaSource是自定义对象 + url: z.string().optional(), + items: z.array(CreatePinBodyItemSchema).optional(), +}) + +export class CreatePinBodyDto extends createZodDto(CreatePinBodySchema) {} + +const WebhookSchema = z.object({ + code: z.string().optional(), + state: z.string().optional(), +}) + +export class WebhookDto extends createZodDto(WebhookSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/core.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/core.module.ts new file mode 100644 index 000000000..306b1b2ed --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/core.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common' +import { McpModule as TheMcpModule } from '@rekog/mcp-nest' +import { SkKeyAuthGuard } from '../common/guards/skKeyAuth.guard' +import { AliGreenModule } from '../core/ali-green/ali-green.module' +import { PublishModule } from '../core/publish/publish.module' +import { AccountModule } from './account/account.module' +import { DataCubeModule } from './dataCube/dataCube.module' +import { EngagementModule } from './engagement/engagement.module' +import { FileModule } from './file/file.module' +import { InteracteModule } from './interact/interact.module' +import { McpModule } from './mcp/mcp.module' +import { MetaModule } from './plat/meta/meta.module' +import { PlatModule } from './plat/plat.module' +import { TiktokModule } from './plat/tiktok/tiktok.module' +import { TwitterModule } from './plat/twitter/twitter.module' +import { WxPlatModule } from './plat/wxPlat/wxPlat.module' +import { YoutubeModule } from './plat/youtube/youtube.module' +import { SkKeyModule } from './skKey/skKey.module' +import { TestModule } from './test/test.module' + +@Module({ + imports: [ + TestModule, + FileModule, + SkKeyModule, + McpModule, + TheMcpModule.forRoot({ + name: 'channel-mcp-server', + version: '1.0.0', + guards: [SkKeyAuthGuard], + }), + AccountModule, + PublishModule, + TwitterModule, + MetaModule, + TiktokModule, + YoutubeModule, + WxPlatModule, + DataCubeModule, + InteracteModule, + EngagementModule, + AliGreenModule, + PlatModule, + ], + providers: [], +}) +export class CoreModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/bilibiliData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/bilibiliData.service.ts new file mode 100644 index 000000000..3795dad20 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/bilibiliData.service.ts @@ -0,0 +1,69 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: b站-统计数据 + */ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { BilibiliService } from '../plat/bilibili/bilibili.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class BilibiliDataService extends DataCubeBase { + private readonly logger = new Logger(BilibiliDataService.name) + constructor( + readonly bilibiliService: BilibiliService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.BILIBILI}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + await this.serverClient.account.updateAccountStatistics(accountId, { + workCount: res.arcNum, + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const res = await this.bilibiliService.getUserStat(accountId) + return { + fensNum: res.follower, + arcNum: res.arc_passed_total, + } + } + + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + async getArcDataCube(accountId: string, dataId: string) { + const res = await this.bilibiliService.getArcStat(accountId, dataId) + + return { + fensNum: res.favorite, + playNum: res.view, + commentNum: res.reply, + likeNum: res.like, + shareNum: res.share, + collectNum: res.favorite, + } + } + + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/data.base.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/data.base.ts new file mode 100644 index 000000000..982d2fa5c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/data.base.ts @@ -0,0 +1,38 @@ +import { + ChannelAccountDataBulk, + ChannelAccountDataCube, + ChannelArcDataBulk, + ChannelArcDataCube, +} from '../plat/common' + +export abstract class DataCubeBase { + /** + * 上报用户数据 + * @param accountId + */ + abstract accountPortraitReport( + accountId: string, + ): Promise + + // 获取账号的统计数据 + abstract getAccountDataCube( + accountId: string, + ): Promise + + // 获取账号的增量数据 + abstract getAccountDataBulk( + accountId: string, + ): Promise + + // 获取作品的统计数据 + abstract getArcDataCube( + accountId: string, + dataId: string, + ): Promise + + // 获取作品的增量数据 + abstract getArcDataBulk( + accountId: string, + dataId: string, + ): Promise +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.controller.ts new file mode 100644 index 000000000..34aa1bed6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.controller.ts @@ -0,0 +1,74 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 平台数据 + */ +import { Body, Controller, Post } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { AccountService } from '../account/account.service' +import { BilibiliDataService } from './bilibiliData.service' +import { DataCubeBase } from './data.base' +import { AccountDto, ArcDto } from './dto/dataCube.dto' +import { WxGzhDataService } from './wxGzhData.service' +import { YoutubeDataService } from './youtubeData.service' + +@Controller() +export class DataCubeController { + private readonly dataCubeMap = new Map() + + constructor( + readonly accountService: AccountService, + readonly bilibiliDataService: BilibiliDataService, + readonly youtubeDataService: YoutubeDataService, + readonly wxGzhDataService: WxGzhDataService, + ) { + this.dataCubeMap.set(AccountType.BILIBILI, bilibiliDataService) + this.dataCubeMap.set(AccountType.YOUTUBE, youtubeDataService) + this.dataCubeMap.set(AccountType.WxGzh, wxGzhDataService) + } + + private async getDataCube(accountId: string) { + const account = await this.accountService.getAccountInfo(accountId) + if (!account) + throw new AppException(1, '账户不存在') + const dataCube = this.dataCubeMap.get(account.type) + if (!dataCube) + throw new AppException(1, '暂不支持该账户类型') + return dataCube + } + + // @NatsMessagePattern('channel.dataCube.getAccountDataCube') + @Post('channel/dataCube/getAccountDataCube') + async getAccountDataCube(@Body() data: AccountDto) { + const dataCube = await this.getDataCube(data.accountId) + const res = await dataCube.getAccountDataCube(data.accountId) + return res + } + + // @NatsMessagePattern('channel.dataCube.getAccountDataBulk') + @Post('channel/dataCube/getAccountDataBulk') + async getAccountDataBulk(@Body() data: AccountDto) { + const dataCube = await this.getDataCube(data.accountId) + const res = await dataCube.getAccountDataBulk(data.accountId) + return res + } + + // @NatsMessagePattern('channel.dataCube.getArcDataCube') + @Post('channel/dataCube/getArcDataCube') + async getArcDataCube(@Body() data: ArcDto) { + const dataCube = await this.getDataCube(data.accountId) + const res = await dataCube.getArcDataCube(data.accountId, data.dataId) + return res + } + + // @NatsMessagePattern('channel.dataCube.getArcDataBulk') + @Post('channel/dataCube/getArcDataBulk') + async getArcDataBulk(@Body() data: ArcDto) { + const dataCube = await this.getDataCube(data.accountId) + const res = await dataCube.getArcDataBulk(data.accountId, data.dataId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.module.ts new file mode 100644 index 000000000..985140cd8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dataCube.module.ts @@ -0,0 +1,23 @@ +import { Module } from '@nestjs/common' +import { PinterestDataService } from '../../core/dataCube/pinterestData.service' +import { PinterestModule } from '../../core/plat/pinterest/pinterest.module' +import { AccountModule } from '../account/account.module' +import { BilibiliModule } from '../plat/bilibili/bilibili.module' +import { MetaModule } from '../plat/meta/meta.module' +import { WxPlatModule } from '../plat/wxPlat/wxPlat.module' +import { YoutubeModule } from '../plat/youtube/youtube.module' +import { BilibiliDataService } from './bilibiliData.service' +import { DataCubeController } from './dataCube.controller' +import { FacebookDataService } from './facebookData.service' +import { InstagramDataService } from './instagram.service' +import { ThreadsDataService } from './threads.service' +import { WxGzhDataService } from './wxGzhData.service' +import { YoutubeDataService } from './youtubeData.service' + +@Module({ + imports: [BilibiliModule, MetaModule, YoutubeModule, WxPlatModule, PinterestModule, AccountModule], + controllers: [DataCubeController], + providers: [BilibiliDataService, FacebookDataService, InstagramDataService, ThreadsDataService, YoutubeDataService, WxGzhDataService, PinterestDataService], + exports: [BilibiliDataService, FacebookDataService, InstagramDataService, ThreadsDataService, YoutubeDataService, WxGzhDataService, PinterestDataService], +}) +export class DataCubeModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dto/dataCube.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dto/dataCube.dto.ts new file mode 100644 index 000000000..7d09f5c7b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/dto/dataCube.dto.ts @@ -0,0 +1,17 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const AccountSchema = z.object({ + accountId: z.string().describe('账号ID'), +}) +export class AccountDto extends createZodDto( + AccountSchema, +) {} + +export const ArcSchema = z.object({ + accountId: z.string().describe('账号ID'), + dataId: z.string(), +}) +export class ArcDto extends createZodDto( + ArcSchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/facebookData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/facebookData.service.ts new file mode 100644 index 000000000..50855b731 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/facebookData.service.ts @@ -0,0 +1,55 @@ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { FacebookService } from '../plat/meta/facebook.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class FacebookDataService extends DataCubeBase { + private readonly logger = new Logger(FacebookDataService.name) + constructor( + readonly facebookService: FacebookService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.FACEBOOK}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + readCount: res.playNum, + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const res = await this.facebookService.getAccountInsights(accountId) + return { + fensNum: res.fensNum, + playNum: res.playNum, + } + } + + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + async getArcDataCube(accountId: string, dataId: string) { + this.logger.log('getArcDataCube', accountId, dataId) + const res = await this.facebookService.getPostInsights(accountId, dataId) + return res || {} + } + + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/instagram.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/instagram.service.ts new file mode 100644 index 000000000..308f87716 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/instagram.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { InstagramInsightsRequest, InstagramMediaInsightsRequest } from '../../libs/instagram/instagram.interfaces' +import { InstagramService } from '../plat/meta/instagram.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class InstagramDataService extends DataCubeBase { + private readonly logger = new Logger(InstagramDataService.name) + constructor( + readonly instagramService: InstagramService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.INSTAGRAM}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + workCount: res.arcNum, + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const query = { + fields: 'media_count,followers_count,follows_count', + } + const res = await this.instagramService.getAccountInfo(accountId, query) + return { + fensNum: res?.followers_count, + arcNum: res?.media_count, + } + } + + // Todo : Implement bulk data retrieval for crawler service + async getAccountDataBulk(accountId: string) { + const query: InstagramInsightsRequest = { + metric: 'comments,likes,replies,shares,views,reach,follows_and_unfollows', + period: 'day', + } + await this.instagramService.getAccountInsights(accountId, query) + return { + list: [], + } + } + + async getArcDataCube(accountId: string, dataId: string) { + const query: InstagramMediaInsightsRequest = { + metric: 'comments,likes,shares,views', + period: 'lifetime', + } + const res = await this.instagramService.getMediaInsights(accountId, dataId, query) + return { + commentNum: res?.data?.filter(item => item.name === 'comments')[0]?.values[0]?.value || 0, + likeNum: res?.data?.filter(item => item.name === 'likes')[0]?.values[0]?.value || 0, + shareNum: res?.data?.filter(item => item.name === 'shares')[0]?.values[0]?.value || 0, + viewNum: res?.data?.filter(item => item.name === 'views')[0]?.values[0]?.value || 0, + } + } + + // Todo : Implement bulk data retrieval for crawler service + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/pinterestData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/pinterestData.service.ts new file mode 100644 index 000000000..f7f2e79fa --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/pinterestData.service.ts @@ -0,0 +1,67 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: b站-统计数据 + */ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { PinterestService } from '../../core/plat/pinterest/pinterest.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class PinterestDataService extends DataCubeBase { + private readonly logger = new Logger(PinterestDataService.name) + constructor( + readonly pinterestService: PinterestService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.PINTEREST}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + workCount: res.arcNum, + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const res: any = await this.pinterestService.getUserStat(accountId) + return { + fensNum: res.userInfo.follower, + arcNum: res.userInfo.monthly_views, + } + } + + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + async getArcDataCube(accountId: string) { + const res: any = await this.pinterestService.getUserStat(accountId) + const { userInfo } = res + return { + fensNum: userInfo.follower_count, + playNum: userInfo.monthly_views, + pin_count: userInfo.pin_count, + board_count: userInfo.board_count, + } + } + + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/threads.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/threads.service.ts new file mode 100644 index 000000000..5f5774b2e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/threads.service.ts @@ -0,0 +1,73 @@ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { ThreadsService } from '../plat/meta/threads.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class ThreadsDataService extends DataCubeBase { + private readonly logger = new Logger(ThreadsDataService.name) + constructor( + readonly threadsService: ThreadsService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.THREADS}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + likeCount: res.likeNum, + commentCount: res.commentNum, + readCount: res.playNum, + collectCount: res.shareNum, + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const query = { + metric: 'likes,replies,followers_count,reposts,views,quotes', + } + const res = await this.threadsService.getAccountInsights(accountId, query) + return { + likeNum: res?.data?.filter(item => item.name === 'likes')?.[0]?.total_value?.value || 0, + commentNum: res?.data?.filter(item => item.name === 'replies')?.[0]?.total_value?.value || 0, + shareNum: res?.data?.filter(item => item.name === 'reposts')?.[0]?.total_value?.value || 0, + fensNum: res?.data?.filter(item => item.name === 'followers_count')?.[0]?.total_value?.value || 0, + playNum: res?.data?.filter(item => item.name === 'views')?.[0]?.total_value?.value || 0, + } + } + + // Todo : Implement bulk data retrieval for crawler service + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + async getArcDataCube(accountId: string, dataId: string) { + const query = { + metric: 'likes,views,replies,shares', + } + const res = await this.threadsService.getMediaInsights(accountId, dataId, query) + return { + commentNum: res?.data?.filter(item => item.name === 'replies')?.[0]?.values?.[0]?.value || 0, + likeNum: res?.data?.filter(item => item.name === 'likes')?.[0]?.values?.[0]?.value || 0, + shareNum: res?.data?.filter(item => item.name === 'shares')?.[0]?.values?.[0]?.value || 0, + viewNum: res?.data?.filter(item => item.name === 'views')?.[0]?.values?.[0]?.value || 0, + } + } + + // Todo : Implement bulk data retrieval for crawler service + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/wxGzhData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/wxGzhData.service.ts new file mode 100644 index 000000000..add95e875 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/wxGzhData.service.ts @@ -0,0 +1,64 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: 微信公众号-统计数据 + */ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import dayjs from 'dayjs' +import { WxGzhService } from '../plat/wxPlat/wxGzh.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class WxGzhDataService extends DataCubeBase { + private readonly logger = new Logger(WxGzhDataService.name) + constructor( + readonly wxGzhService: WxGzhService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.WxGzh}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + fansCount: res.fensNum, + }) + } + + async getAccountDataCube(accountId: string) { + const [startTime, endTime] = [dayjs().startOf('day').format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')] + const res = await this.wxGzhService.getusercumulate(accountId, startTime, endTime) + const lastData = res.list[0] + + return { + fensNum: lastData.cumulate_user, + } + } + + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + async getArcDataCube(accountId: string, dataId: string) { + this.logger.log('getArcDataCube', accountId, dataId) + return { + } + } + + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/youtubeData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/youtubeData.service.ts new file mode 100644 index 000000000..ac5d579bd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/dataCube/youtubeData.service.ts @@ -0,0 +1,78 @@ +/* + * @Author: zhangwei + * @Date: 2025-08-04 21:25:55 + * @LastEditTime: 2025-08-04 21:25:55 + * @LastEditors: zhangwei + * @Description: YouTube-统计数据 + */ +import { Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { YoutubeService } from '../plat/youtube/youtube.service' +import { DataCubeBase } from './data.base' + +@Injectable() +export class YoutubeDataService extends DataCubeBase { + private readonly logger = new Logger(YoutubeDataService.name) + constructor( + readonly youtubeService: YoutubeService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + @OnEvent(`account.create.${AccountType.YOUTUBE}`) + async accountPortraitReport(accountId: string) { + const res = await this.getAccountDataCube(accountId) + this.serverClient.account.updateAccountStatistics(accountId, { + fansCount: res.fensNum, + workCount: res.arcNum, + readCount: res.playNum, + }) + } + + // 账户数据 + async getAccountDataCube(accountId: string) { + this.logger.log(`getAccountDataCube accountId: ${accountId}`) + const res = await this.youtubeService.getChannelsList(accountId, undefined, undefined, undefined, true) + const statData = res.data.items[0].statistics + + return { + fensNum: Number.parseInt(statData.subscriberCount) || 0, + arcNum: Number.parseInt(statData.videoCount) || 0, + playNum: Number.parseInt(statData.viewCount) || 0, + } + } + + // 账户数据增量 + async getAccountDataBulk(accountId: string) { + this.logger.log('getAccountDataBulk', accountId) + return { + list: [], + } + } + + // 作品数据 + async getArcDataCube(accountId: string, dataId: string) { + this.logger.log('getArcDataCube', accountId, dataId) + const res = await this.youtubeService.getVideosList(accountId, undefined, [dataId]) + const statData = res.data.items[0].statistics + + return { + fensNum: Number.parseInt(statData.favoriteCount) || 0, + likeNum: Number.parseInt(statData.likeCount) || 0, + playNum: Number.parseInt(statData.viewCount) || 0, + commentNum: Number.parseInt(statData.commentCount) || 0, + } + } + + // 作品数据增量 + async getArcDataBulk(accountId: string, dataId: string) { + this.logger.log('getArcDataBulk', accountId, dataId) + return { + recordId: '', + dataId: '', + list: [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/ai.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/ai.dto.ts new file mode 100644 index 000000000..093b31fd8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/ai.dto.ts @@ -0,0 +1,16 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const CommentSchema = z.object({ + id: z.string(), + comment: z.string(), +}) + +export const ReplyToCommentAnswerSchema = z.object({ + id: z.string(), + comment: z.string(), + reply: z.string(), +}) + +export class comment extends createZodDto(CommentSchema) {} +export class ReplyToCommentAnswer extends createZodDto(ReplyToCommentAnswerSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/task.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/task.dto.ts new file mode 100644 index 000000000..6145f0b03 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/dto/task.dto.ts @@ -0,0 +1,35 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { EngagementTargetScope, EngagementTaskStatus, EngagementTaskType } from '../../../libs/database/schema/engagement.task.schema' + +export const EngagementTask = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID'), + userId: z.string(), + postId: z.string(), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest']).describe('平台'), + status: z.enum(EngagementTaskStatus).default(EngagementTaskStatus.CREATED), + taskType: z.enum(EngagementTaskType).default(EngagementTaskType.REPLY), + targetScope: z.enum(EngagementTargetScope).default(EngagementTargetScope.ALL), + prompt: z.string().min(1).max(500).optional(), + model: z.string(), + targetIds: z.array(z.string()).nullable().default([]), + subTaskCount: z.number().default(0), + completedSubTaskCount: z.number().default(0), + failedSubTaskCount: z.number().default(0), +}) + +export const EngagementSubTask = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID'), + postId: z.string(), + userId: z.string(), + platform: z.string(), + status: z.enum(EngagementTaskStatus).default(EngagementTaskStatus.CREATED), + taskType: z.enum(EngagementTaskType).default(EngagementTaskType.REPLY), + taskId: z.string(), + commentId: z.string(), + commentContent: z.string(), + replyContent: z.string().optional(), +}) + +export class CreateEngagementTaskDto extends createZodDto(EngagementTask) {} +export class CreateEngagementSubTaskDto extends createZodDto(EngagementSubTask) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.controller.ts new file mode 100644 index 000000000..3f983c1ff --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { AIGenCommentDto, FetchCommentRepliesRequest, FetchPostCommentsRequest, PublishCommentReplyRequest, PublishCommentRequest, ReplyToCommentsDto } from './engagement.dto' +import { PublishCommentResponse } from './engagement.interface' +import { EngagementService } from './engagement.service' + +@Controller() +export class EngagementController { + constructor( + private readonly engagementService: EngagementService, + ) {} + + // @NatsMessagePattern('channel.engagement.list.post.comments') + @Post('channel/engagement/list/post/comments') + async fetchPostComments(@Body() data: FetchPostCommentsRequest) { + return this.engagementService.fetchPostComments(data) + } + + // @NatsMessagePattern('channel.engagement.list.comment.replies') + @Post('channel/engagement/list/comment/replies') + async fetchCommentReplies(@Body() data: FetchCommentRepliesRequest) { + return this.engagementService.fetchCommentReplies(data) + } + + // @NatsMessagePattern('channel.engagement.publish.post.comment') + @Post('channel/engagement/publish/post/comment') + async commentOnPost(@Body() data: PublishCommentRequest): Promise { + return this.engagementService.commentOnPost(data) + } + + // @NatsMessagePattern('channel.engagement.publish.comment.reply') + @Post('channel/engagement/publish/comment/reply') + async replyToComment(@Body() data: PublishCommentReplyRequest): Promise { + return this.engagementService.replyToComment(data) + } + + // @NatsMessagePattern('channel.engagement.ai.generate.replies') + @Post('channel/engagement/ai/generate/replies') + async generateRepliesByAI(@Body() data: AIGenCommentDto) { + return this.engagementService.batchGenReplyContent(data) + } + + // @NatsMessagePattern('channel.engagement.ai.reply.to.comments') + @Post('channel/engagement/ai/reply/to/comments') + async replyToCommentByAI(@Body() data: ReplyToCommentsDto) { + return this.engagementService.ReplyToCommentsByAI(data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.dto.ts new file mode 100644 index 000000000..41be4876d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.dto.ts @@ -0,0 +1,82 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const KeysetPaginationSchema = z.object({ + before: z.string().nullish().describe('前一页游标'), + after: z.string().nullish().describe('后一页游标'), + limit: z.number().min(1).max(100).nullish().describe('每页数量,默认20'), +}) + +export const OffsetPaginationSchema = z.object({ + pageNo: z.number({ message: '页码' }).min(1).nullish().default(1).describe('页码,默认1'), + pageSize: z.number({ message: '每页数量' }).min(1).max(100).nullish().default(20).describe('每页数量,默认20'), +}) + +export const fetchPostCommentsRequestSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + postId: z.string().describe('作品ID'), + pagination: z.union([KeysetPaginationSchema, OffsetPaginationSchema]).nullish().describe('分页参数'), +}) + +export const fetchCommentRepliesSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + commentId: z.string().describe('评论ID'), + pagination: z.union([KeysetPaginationSchema, OffsetPaginationSchema]).nullish().describe('分页参数'), +}) + +export const PublishCommentRequestSchema = z.object({ + accountId: z.string().describe('账号ID'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + postId: z.string().describe('作品ID'), + message: z.string().min(1).max(500).describe('评论内容, 最大500字符'), +}) + +export const publishCommentReplyRequestSchema = z.object({ + accountId: z.string().describe('账号ID'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + commentId: z.string().describe('评论ID'), + message: z.string().min(1).max(500).describe('评论内容, 最大500字符'), +}) + +export const AIGenCommentConfigSchema = z.object({ + accountId: z.string().describe('账号ID'), + prompt: z.string().min(1).max(500).describe('提示语, 最大500字符'), + maxTokens: z.number().min(10).max(1000).nullish().default(100).describe('AI生成内容的最大长度, 默认100'), + temperature: z.number().min(0).max(1).nullish().default(0.7).describe('AI生成内容的随机性, 0-1之间, 默认0.7'), + model: z.string().describe('AI模型名称, 例如:gpt-3.5-turbo, gpt-4'), +}) + +export const CommentSchema = z.object({ + id: z.string(), + comment: z.string(), +}) + +export const ReplyToCommentsSchema = z.object({ + accountId: z.string().describe('账号ID'), + userId: z.string().describe('用户ID'), + postId: z.string().describe('作品ID'), + prompt: z.string().min(1).max(500).optional().describe('提示语, 最大500字符'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest']).describe('平台'), + model: z.string().describe('AI模型名称, 例如:gpt-3.5-turbo, gpt-4'), + comments: z.array(CommentSchema).optional().describe('评论列表'), +}) + +export const AIGenCommentSchema = z.object({ + userId: z.string().describe('用户ID'), + model: z.string().describe('AI模型名称, 例如:gpt-3.5-turbo, gpt-4'), + prompt: z.string().min(1).max(500).nullable().describe('提示语, 最大500字符'), + comments: z.array(CommentSchema).describe('评论列表'), +}) + +export class FetchPostCommentsRequest extends createZodDto(fetchPostCommentsRequestSchema) {} +export class FetchCommentRepliesRequest extends createZodDto(fetchCommentRepliesSchema) {} +export class KeysetPagination extends createZodDto(KeysetPaginationSchema) {} +export class OffsetPagination extends createZodDto(OffsetPaginationSchema) {} +export class PublishCommentRequest extends createZodDto(PublishCommentRequestSchema) {} +export class PublishCommentReplyRequest extends createZodDto(publishCommentReplyRequestSchema) {} +export class AIGenCommentConfig extends createZodDto(AIGenCommentConfigSchema) {} +export class Comment extends createZodDto(CommentSchema) {} +export class ReplyToCommentsDto extends createZodDto(ReplyToCommentsSchema) {} +export class AIGenCommentDto extends createZodDto(AIGenCommentSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.interface.ts new file mode 100644 index 000000000..8ba7a471a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.interface.ts @@ -0,0 +1,79 @@ +import { KeysetPagination, OffsetPagination } from './engagement.dto' + +/** + * Supported social media platforms + */ +export type Platform = 'facebook' | 'instagram' | 'twitter' | 'youtube' | 'tiktok' + +/** + * Pagination strategy types + */ +export type PaginationType = 'keyset' | 'offset' + +/** + * Cursor information for keyset pagination response + */ +export interface KeysetPaginationCursor { + /** Cursor for previous page */ + before?: string + /** Cursor for next page */ + after?: string +} + +export interface EngagementComment { + id: string + message: string + author: { + username: string + avatar?: string + } + createdAt: string + hasReplies?: boolean +} + +/** + * Response structure for post comments + * @template T The type of comment data + */ +export interface FetchPostCommentsResponse { + /** Array of comments */ + comments: EngagementComment[] + /** Total number of comments (for offset pagination) */ + total?: number + /** Pagination cursor (for keyset pagination) */ + cursor: KeysetPagination +} + +export interface PublishCommentResponse { + id?: string + success: boolean + error?: string +} + +/** + * Interface for engagement service implementations + * @template T The type of comment data returned by the platform + */ +export interface EngagementProvider { + /** + * Fetches comments on a specific post + * @param request - The request parameters including post ID and pagination + * @returns Promise resolving to comments response + */ + fetchPostComments: (accountId: string, postId: string, pagination: KeysetPagination | OffsetPagination | null) => Promise + /** + * Fetches replies to a specific comment + * @param request - The request parameters including comment ID and pagination + * @returns Promise resolving to comments response + */ + fetchCommentReplies: (accountId: string, commentId: string, pagination: KeysetPagination | OffsetPagination | null) => Promise + + /** + * comment on a specific post + * @param postId + * @param message + * @returns + */ + commentOnPost: (accountId: string, postId: string, message: string) => Promise + replyToComment: (accountId: string, commentId: string, message: string) => Promise +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.module.ts new file mode 100644 index 000000000..365ba8afe --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { EngagementSubTask, EngagementSubTaskSchema, EngagementTask, EngagementTaskSchema } from '../../libs/database/schema/engagement.task.schema' +import { MetaModule } from '../plat/meta/meta.module' +import { YoutubeModule } from '../plat/youtube/youtube.module' +import { EngagementController } from './engagement.controller' +import { EngagementRecordService } from './engagement.record.service' +import { EngagementService } from './engagement.service' +import { FacebookEngagementProvider } from './providers/facebook.provider' +import { InstagramEngagementProvider } from './providers/instagram.provider' +import { ThreadsEngagementProvider } from './providers/threads.provider' +import { YoutubeEngagementProvider } from './providers/youtube.provider' +import { EngagementTaskDistributionConsumer } from './workers/distribute-engagement-task.consumer' +import { EngagementReplyToCommentConsumer } from './workers/reply-to-comment.consumer' + +@Module({ + imports: [ + MetaModule, + YoutubeModule, + MongooseModule.forFeature([ + { name: EngagementTask.name, schema: EngagementTaskSchema }, + { name: EngagementSubTask.name, schema: EngagementSubTaskSchema }, + ]), + + ], + controllers: [EngagementController], + providers: [ + FacebookEngagementProvider, + InstagramEngagementProvider, + ThreadsEngagementProvider, + YoutubeEngagementProvider, + EngagementService, + EngagementRecordService, + EngagementTaskDistributionConsumer, + EngagementReplyToCommentConsumer, + ], + exports: [ + FacebookEngagementProvider, + InstagramEngagementProvider, + ThreadsEngagementProvider, + YoutubeEngagementProvider, + EngagementService, + EngagementRecordService, + EngagementTaskDistributionConsumer, + EngagementReplyToCommentConsumer, + ], +}) +export class EngagementModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.record.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.record.service.ts new file mode 100644 index 000000000..182438ded --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.record.service.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { EngagementSubTask, EngagementTask, EngagementTaskStatus } from '../../libs/database/schema/engagement.task.schema' +import { CreateEngagementSubTaskDto, CreateEngagementTaskDto } from './dto/task.dto' + +@Injectable() +export class EngagementRecordService { + constructor( + @InjectModel(EngagementTask.name) + private readonly engagementTaskModel: Model, + @InjectModel(EngagementSubTask.name) + private readonly engagementSubTaskModel: Model, + ) {} + + async createEngagementTask( + data: CreateEngagementTaskDto, + ): Promise { + const subPublishTask = new this.engagementTaskModel(data) + return subPublishTask.save() + } + + async getEngagementTask(taskId: string): Promise { + return this.engagementTaskModel.findById(taskId) + } + + async searchEngagementTaskInProgress(postId: string, status: EngagementTaskStatus): Promise { + return this.engagementTaskModel.find({ postId, status: { $ne: status } }) + } + + async createEngagementSubTask(data: CreateEngagementSubTaskDto): Promise { + const subPublishTask = new this.engagementSubTaskModel(data) + return subPublishTask.save() + } + + async searchEngagementSubTasksByCommentId(postId: string, commentId: string, status: EngagementTaskStatus): Promise { + return this.engagementSubTaskModel.find({ postId, commentId, status }) + } + + async queryEngagementSubTasksByTaskId(taskId: string): Promise { + return this.engagementSubTaskModel.find({ taskId, status: { $ne: EngagementTaskStatus.COMPLETED } }) + } + + async getEngagementSubTask(subTaskId: string): Promise { + return this.engagementSubTaskModel.findById(subTaskId) + } + + async updateEngagementTask(taskId: string, updateData: Partial): Promise { + return this.engagementTaskModel.findByIdAndUpdate(taskId, updateData) + } + + async updateEngagementSubTask(subTaskId: string, updateData: Partial): Promise { + return this.engagementSubTaskModel.findByIdAndUpdate(subTaskId, updateData) + } + + async updateEngagementTaskStatus(taskId: string, status: EngagementTaskStatus): Promise { + return this.engagementTaskModel.findByIdAndUpdate(taskId, { status }) + } + + async updateEngagementSubTaskStatus(subTaskId: string, status: EngagementTaskStatus): Promise { + return this.engagementSubTaskModel.findByIdAndUpdate(subTaskId, { status }) + } + + async incrementEngagementTaskFailedCounters(taskId: string, count: number): Promise { + return this.engagementTaskModel.findByIdAndUpdate(taskId, { $inc: { failedSubTaskCount: count } }) + } + + async incrementEngagementTaskTotalSubTasks(taskId: string, count: number): Promise { + return this.engagementTaskModel.findByIdAndUpdate(taskId, { $inc: { subTaskCount: count } }) + } + + async incrementEngagementTaskCompletedSubTasks(taskId: string, count: number): Promise { + return this.engagementTaskModel.findByIdAndUpdate(taskId, { $inc: { completedSubTaskCount: count } }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.service.ts new file mode 100644 index 000000000..291d53092 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/engagement.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from '@nestjs/common' +import { QueueService } from '@yikart/aitoearn-queue' +import { AitoearnServerClientService, UserChatCompletionDto } from '@yikart/aitoearn-server-client' +import { AppException, UserType } from '@yikart/common' +import { EngagementTargetScope, EngagementTaskStatus, EngagementTaskType } from '../../libs/database/schema/engagement.task.schema' +import { ReplyToCommentAnswer } from './dto/ai.dto' +import { AIGenCommentDto, FetchCommentRepliesRequest, FetchPostCommentsRequest, PublishCommentReplyRequest, PublishCommentRequest, ReplyToCommentsDto } from './engagement.dto' +import { EngagementProvider, PublishCommentResponse } from './engagement.interface' +import { EngagementRecordService } from './engagement.record.service' +import { FacebookEngagementProvider } from './providers/facebook.provider' +import { InstagramEngagementProvider } from './providers/instagram.provider' +import { ThreadsEngagementProvider } from './providers/threads.provider' +import { YoutubeEngagementProvider } from './providers/youtube.provider' + +@Injectable() +export class EngagementService { + private readonly providerMap = new Map() + constructor( + facebookProvider: FacebookEngagementProvider, + instagramProvider: InstagramEngagementProvider, + threadsProvider: ThreadsEngagementProvider, + youtubeProvider: YoutubeEngagementProvider, + private readonly serverClient: AitoearnServerClientService, + private readonly engagementRecordService: EngagementRecordService, + private readonly queueService: QueueService, + ) { + this.providerMap.set('facebook', facebookProvider) + this.providerMap.set('instagram', instagramProvider) + this.providerMap.set('threads', threadsProvider) + this.providerMap.set('youtube', youtubeProvider) + } + + private getProvider(providerKey: string): EngagementProvider { + const provider = this.providerMap.get(providerKey) + if (!provider) { + throw new Error(`Engagement provider for ${providerKey} not found`) + } + return provider + } + + async fetchPostComments(data: FetchPostCommentsRequest) { + const provider = this.getProvider(data.platform) + return provider.fetchPostComments(data.accountId, data.postId, data.pagination || null) + } + + async fetchCommentReplies(data: FetchCommentRepliesRequest) { + const provider = this.getProvider(data.platform) + return provider.fetchCommentReplies(data.accountId, data.commentId, data.pagination || null) + } + + async commentOnPost(data: PublishCommentRequest): Promise { + const provider = this.getProvider(data.platform) + return provider.commentOnPost(data.accountId, data.postId, data.message) + } + + async replyToComment(data: PublishCommentReplyRequest): Promise { + const provider = this.getProvider(data.platform) + return provider.replyToComment(data.accountId, data.commentId, data.message) + } + + async batchGenReplyContent(data: AIGenCommentDto): Promise> { + const aiChatReq: UserChatCompletionDto = { + userId: data.userId, + userType: UserType.User, + messages: [ + { + role: 'system', + content: 'You are a senior social media strategist.', + }, + { + role: 'user', + content: 'I will provide you with a list of comments in the format: [{id: string, comment: string}].\n\nYour task:\n- Generate a professional and engaging reply for each comment\n- Keep replies concise and under 50 words\n- Maintain a positive and friendly tone, encouraging further interaction\n- The reply must be written in the same language as the comment\n- Return a strict JSON array, with each element formatted as: {id: string, comment: string, reply: string}\n\nImportant: The output must contain only the JSON array — no explanations, no extra text, and no code blocks.', + }, + { + role: 'user', + content: JSON.stringify(data.comments), + }, + ], + model: data.model, + } + if (data.prompt && data.prompt.length > 0) { + aiChatReq.messages.push({ + role: 'user', + content: data.prompt, + }) + } + const resp = await this.serverClient.ai.chatCompletion(aiChatReq) + const replyMap: Record = {} + const replyList: ReplyToCommentAnswer[] = JSON.parse(resp.content as string) + for (const replyItem of replyList) { + replyMap[replyItem.id] = replyItem.reply + } + return replyMap + } + + async ReplyToCommentsByAI(data: ReplyToCommentsDto): Promise<{ id: string }> { + let targetScope = EngagementTargetScope.ALL + if (data.comments?.length && data.comments.length > 0) { + targetScope = EngagementTargetScope.PARTIAL + } + const tasks = await this.engagementRecordService.searchEngagementTaskInProgress(data.postId, EngagementTaskStatus.FAILED) + if (tasks && tasks.length > 0) { + throw new AppException(1, 'Reply task for this post is already in progress, please try again later.') + } + const task = await this.engagementRecordService.createEngagementTask({ + accountId: data.accountId, + userId: data.userId, + postId: data.postId, + taskType: EngagementTaskType.REPLY, + targetScope, + prompt: data.prompt, + model: data.model, + platform: data.platform, + targetIds: data.comments ? data.comments.map(c => c.id) : [], + status: EngagementTaskStatus.CREATED, + subTaskCount: 0, + completedSubTaskCount: 0, + failedSubTaskCount: 0, + }) + if (data.comments && data.comments.length > 0) { + for (const comment of data.comments) { + await this.engagementRecordService.createEngagementSubTask({ + accountId: data.accountId, + userId: data.userId, + postId: data.postId, + platform: data.platform, + taskType: EngagementTaskType.REPLY, + taskId: task.id, + commentId: comment.id, + commentContent: comment.comment, + status: EngagementTaskStatus.CREATED, + replyContent: '', + }) + } + } + await this.queueService.addEngagementTaskDistributionJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 0, + removeOnComplete: true, + removeOnFail: true, + }, + ) + return { id: task.id } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/facebook.provider.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/facebook.provider.ts new file mode 100644 index 000000000..a38beb051 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/facebook.provider.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common' +import { FacebookService } from '../../../core/plat/meta/facebook.service' +import { FacebookPostCommentsRequest } from '../../../libs/facebook/facebook.interfaces' +import { KeysetPagination, OffsetPagination } from '../engagement.dto' +import { EngagementComment, EngagementProvider, FetchPostCommentsResponse, PublishCommentResponse } from '../engagement.interface' + +@Injectable() +export class FacebookEngagementProvider implements EngagementProvider { + public readonly paginationType = 'keyset' + + constructor( + private readonly FacebookService: FacebookService, + ) {} + + private async fetchFacebookObjectComments(accountId: string, targetId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const query: FacebookPostCommentsRequest = { + filter: 'toplevel', + fields: 'id,message,from{name,picture{url}},created_time,comment_count', + order: 'reverse_chronological', + } + if ((pagination as KeysetPagination)?.before) { + query.before = (pagination as KeysetPagination).before || '' + } + if ((pagination as KeysetPagination)?.after) { + query.after = (pagination as KeysetPagination).after || '' + } + const resp = await this.FacebookService.fetchObjectComments(accountId, targetId, query) + const comments: EngagementComment[] = [] + for (const item of resp?.data || []) { + comments.push({ + id: item.id, + message: item.message, + author: { + username: item.from?.name || 'Unknown', + avatar: item.from?.picture?.data?.url || '', + }, + createdAt: item.created_time, + hasReplies: (item.comment_count || 0) > 0, + }) + } + const result: FetchPostCommentsResponse = { + comments, + cursor: { + before: resp?.paging?.cursors?.before || '', + after: resp?.paging?.cursors?.after || '', + } as KeysetPagination, + } + return result + } + + private async publishFacebookObjectComment(accountId: string, targetId: string, message: string): Promise { + const resp = await this.FacebookService.publishPlaintextComment(accountId, targetId, message) + if (resp?.id) { + return { + id: resp.id, + success: true, + } + } + else { + return { + success: false, + error: 'Failed to publish comment', + } + } + } + + async fetchPostComments(accountId: string, postId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + if (pagination && 'offset' in pagination) { + throw new Error('Facebook provider only supports keyset pagination') + } + return this.fetchFacebookObjectComments(accountId, postId, pagination) + } + + async fetchCommentReplies(accountId: string, commentId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + if (pagination && 'offset' in pagination) { + throw new Error('Facebook provider only supports keyset pagination') + } + return this.fetchFacebookObjectComments(accountId, commentId, pagination) + } + + async commentOnPost(accountId: string, postId: string, message: string): Promise { + return this.publishFacebookObjectComment(accountId, postId, message) + } + + async replyToComment(accountId: string, commentId: string, message: string): Promise { + return this.publishFacebookObjectComment(accountId, commentId, message) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/instagram.provider.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/instagram.provider.ts new file mode 100644 index 000000000..1120bdf07 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/instagram.provider.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common' +import { InstagramService } from '../../../core/plat/meta/instagram.service' +import { IGPostCommentsRequest } from '../../../libs/instagram/instagram.interfaces' +import { KeysetPagination, OffsetPagination } from '../engagement.dto' +import { EngagementComment, EngagementProvider, FetchPostCommentsResponse, PublishCommentResponse } from '../engagement.interface' + +@Injectable() +export class InstagramEngagementProvider implements EngagementProvider { + constructor( + private readonly instagramService: InstagramService, + ) { } + + async fetchPostComments(accountId: string, postId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const query: IGPostCommentsRequest = { + fields: 'id,text,timestamp,from,replies', + } + if ((pagination as KeysetPagination)?.before) { + query.before = (pagination as KeysetPagination).before || '' + } + if ((pagination as KeysetPagination)?.after) { + query.after = (pagination as KeysetPagination).after || '' + } + const resp = await this.instagramService.fetchPostComments(accountId, postId, query) + const comments: EngagementComment[] = [] + for (const item of resp?.data || []) { + comments.push({ + id: item.id, + message: item.text, + author: { + username: item.from?.username || 'Unknown', + avatar: '', // Instagram API does not provide avatar in comments + }, + createdAt: item.timestamp, + hasReplies: (item.replies?.data.length || 0) > 0, + }) + } + const result: FetchPostCommentsResponse = { + comments, + cursor: { + before: resp?.paging?.cursors?.before || '', + after: resp?.paging?.cursors?.after || '', + }, + } + return result + } + + async fetchCommentReplies(accountId: string, commentId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const query: IGPostCommentsRequest = { + fields: 'id,text,timestamp,from', + } + if ((pagination as KeysetPagination)?.before) { + query.before = (pagination as KeysetPagination).before || '' + } + if ((pagination as KeysetPagination)?.after) { + query.after = (pagination as KeysetPagination).after || '' + } + const resp = await this.instagramService.fetchCommentReplies(accountId, commentId, query) + const comments: EngagementComment[] = [] + for (const item of resp?.data || []) { + comments.push({ + id: item.id, + message: item.text, + author: { + username: item.from?.username || 'Unknown', + avatar: '', // Instagram API does not provide avatar in comments + }, + createdAt: item.timestamp, + hasReplies: false, + }) + } + const result: FetchPostCommentsResponse = { + comments, + cursor: { + before: resp?.paging?.cursors?.before || '', + after: resp?.paging?.cursors?.after || '', + }, + } + return result + } + + async commentOnPost(accountId: string, postId: string, message: string): Promise { + const result: PublishCommentResponse = { + success: false, + error: 'Failed to publish comment', + } + const resp = await this.instagramService.publishPlaintextComment(accountId, postId, message) + if (resp?.id) { + result.id = resp.id + result.success = true + result.error = '' + } + return result + } + + async replyToComment(accountId: string, commentId: string, message: string): Promise { + const result: PublishCommentResponse = { + success: false, + error: 'Failed to publish comment', + } + const resp = await this.instagramService.publishPlaintextCommentReply(accountId, commentId, message) + if (resp?.id) { + result.id = resp.id + result.success = true + result.error = '' + } + return result + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/threads.provider.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/threads.provider.ts new file mode 100644 index 000000000..4ae2929b7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/threads.provider.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common' +import { ThreadsService } from '../../../core/plat/meta/threads.service' +import { ThreadsObjectCommentsRequest } from '../../../libs/threads/threads.interfaces' +import { KeysetPagination, OffsetPagination } from '../engagement.dto' +import { EngagementComment, EngagementProvider, FetchPostCommentsResponse, PublishCommentResponse } from '../engagement.interface' + +@Injectable() +export class ThreadsEngagementProvider implements EngagementProvider { + constructor( + private readonly threadsService: ThreadsService, + ) { } + + private async fetchThreadsComments(accountId: string, targetId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const query: ThreadsObjectCommentsRequest = { + fields: 'id,text,timestamp,has_replies,username', + reverse: true, + } + if ((pagination as KeysetPagination)?.before) { + query.before = (pagination as KeysetPagination).before || '' + } + if ((pagination as KeysetPagination)?.after) { + query.after = (pagination as KeysetPagination).after || '' + } + const resp = await this.threadsService.fetchObjectComments(accountId, targetId, query) + const comments: EngagementComment[] = [] + for (const item of resp?.data || []) { + comments.push({ + id: item.id, + message: item.text, + author: { + username: item.username || 'Unknown', + avatar: '', // Threads API does not provide avatar in comments + }, + createdAt: item.timestamp, + hasReplies: item.has_replies || false, + }) + } + const result = { + comments, + cursor: { + before: resp?.paging?.cursors?.before || '', + after: resp?.paging?.cursors?.after || '', + }, + } + return result + } + + private async publishThreadsComment(accountId: string, targetId: string, message: string): Promise { + const resp = await this.threadsService.publishPlaintextComment(accountId, targetId, message) + if (resp?.id) { + return { + id: resp.id, + success: true, + } + } + else { + return { + success: false, + error: 'Failed to publish comment', + } + } + } + + async fetchPostComments(accountId: string, postId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + return this.fetchThreadsComments(accountId, postId, pagination) + } + + async fetchCommentReplies(accountId: string, commentId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + return this.fetchThreadsComments(accountId, commentId, pagination) + } + + async commentOnPost(accountId: string, postId: string, message: string): Promise { + return this.publishThreadsComment(accountId, postId, message) + } + + async replyToComment(accountId: string, commentId: string, message: string): Promise { + return this.publishThreadsComment(accountId, commentId, message) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/youtube.provider.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/youtube.provider.ts new file mode 100644 index 000000000..66e5c2bea --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/providers/youtube.provider.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common' +import { YoutubeService } from '../../../core/plat/youtube/youtube.service' +import { KeysetPagination, OffsetPagination } from '../engagement.dto' +import { EngagementComment, EngagementProvider, FetchPostCommentsResponse, PublishCommentResponse } from '../engagement.interface' + +@Injectable() +export class YoutubeEngagementProvider implements EngagementProvider { + private readonly logger = new Logger(YoutubeEngagementProvider.name) + constructor( + private readonly youtubeService: YoutubeService, + ) { } + + private async fetchYoutubeCommentsThreads(accountId: string, videoId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const resp = await this.youtubeService.getCommentThreadsList( + accountId, + undefined, // allThreadsRelatedToChannelId + undefined, // id + videoId, + (pagination as KeysetPagination)?.limit || undefined, // maxResults + (pagination as KeysetPagination)?.after || undefined, // pageToken + undefined, // order + undefined, // searchTerms + ) + this.logger.log(`fetchYoutubeCommentsThreads result: - ${JSON.stringify(resp)}`) + + const comments: EngagementComment[] = [] + for (const item of resp?.items || []) { + comments.push({ + id: item.id, + message: item.snippet.topLevelComment.snippet.textDisplay, + author: { + username: item.snippet.topLevelComment.snippet.authorDisplayName || 'Unknown', + avatar: item.snippet.topLevelComment.snippet.authorProfileImageUrl || '', // Threads API does not provide avatar in comments + }, + createdAt: item.snippet.topLevelComment.snippet.publishAt, + hasReplies: (item.replies?.comments.length || 0) > 0, + }) + } + const result = { + comments, + cursor: { + before: '', + after: resp?.nextPageToken || '', + }, + } + return result + } + + private async fetchYoutubeComments(accountId: string, parentId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + const resp = await this.youtubeService.getCommentsList( + accountId, + parentId, + undefined, + (pagination as KeysetPagination)?.limit || undefined, // maxResults + (pagination as KeysetPagination)?.after || undefined, // pageToken + ) + this.logger.log(`fetchYoutubeComments result: - ${JSON.stringify(resp)}`) + + const comments: EngagementComment[] = [] + for (const item of resp?.items || []) { + comments.push({ + id: item.id, + message: item.snippet.snippet.textDisplay, + author: { + username: item.snippet.authorDisplayName || 'Unknown', + avatar: item.snippet.authorProfileImageUrl || '', // Threads API does not provide avatar in comments + }, + createdAt: item.snippet.publishAt, + hasReplies: false, + }) + } + const result = { + comments, + cursor: { + before: '', + after: resp?.nextPageToken || '', + }, + } + return result + } + + private async publishYoutubeCommentThreads(accountId: string, targetId: string, message: string): Promise { + const resp = await this.youtubeService.insertCommentThreads(accountId, undefined, targetId, message) + + if (resp?.id) { + return { + id: resp.id, + success: true, + } + } + else { + return { + success: false, + error: 'Failed to publish comment', + } + } + } + + private async publishYoutubeComment(accountId: string, targetId: string, message: string): Promise { + const resp = await this.youtubeService.insertComment(accountId, targetId, message) + + if (resp?.id) { + return { + id: resp.id, + success: true, + } + } + else { + return { + success: false, + error: 'Failed to publish comment', + } + } + } + + async fetchPostComments(accountId: string, postId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + return this.fetchYoutubeCommentsThreads(accountId, postId, pagination) + } + + async fetchCommentReplies(accountId: string, commentId: string, pagination: KeysetPagination | OffsetPagination | null): Promise { + return this.fetchYoutubeComments(accountId, commentId, pagination) + } + + async commentOnPost(accountId: string, postId: string, message: string): Promise { + return this.publishYoutubeCommentThreads(accountId, postId, message) + } + + async replyToComment(accountId: string, commentId: string, message: string): Promise { + return this.publishYoutubeComment(accountId, commentId, message) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/distribute-engagement-task.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/distribute-engagement-task.consumer.ts new file mode 100644 index 000000000..b05e3c047 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/distribute-engagement-task.consumer.ts @@ -0,0 +1,169 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger, OnModuleDestroy } from '@nestjs/common' +import { QueueName, QueueService } from '@yikart/aitoearn-queue' +import { Job } from 'bullmq' +import { EngagementSubTask, EngagementTask, EngagementTaskStatus, EngagementTaskType } from '../../../libs/database/schema/engagement.task.schema' +import { AIGenCommentDto, Comment, FetchPostCommentsRequest, KeysetPagination, OffsetPagination } from '../engagement.dto' +import { EngagementRecordService } from '../engagement.record.service' +import { EngagementService } from '../engagement.service' + +@Processor(QueueName.EngagementTaskDistribution, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class EngagementTaskDistributionConsumer extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(EngagementTaskDistributionConsumer.name) + constructor( + private readonly engagementRecordService: EngagementRecordService, + private readonly engagementService: EngagementService, + private readonly queueService: QueueService, + ) { + super() + } + + private async publish(task: EngagementSubTask) { + await this.queueService.addEngagementReplyToCommentJob( + { + taskId: task.id, + attempts: 0, + }, + { + jobId: `${task.platform}:reply_to_comment:task:${task.id}`, + attempts: 0, + }, + ) + } + + private async distributePartialCommentsTask(task: EngagementTask) { + const subTasks = await this.engagementRecordService.queryEngagementSubTasksByTaskId(task.id) + let taskPublishedCount = 0 + try { + const comments: Comment[] = [] + for (const subTask of subTasks) { + comments.push({ id: subTask.commentId, comment: subTask.commentContent }) + } + const data: AIGenCommentDto = { + userId: task.userId, + comments, + model: task.model, + prompt: task.prompt || '', + } + const resp = await this.engagementService.batchGenReplyContent(data) + for (const subTask of subTasks) { + const replyContent = resp[subTask.commentId] + if (replyContent && replyContent.length > 0) { + await this.engagementRecordService.updateEngagementSubTask(subTask.id, { replyContent }) + await this.publish(subTask) + taskPublishedCount += 1 + } + else { + this.logger.warn(`[task-${task.id}] No reply content generated for comment ${subTask.commentId}`) + } + } + await this.engagementRecordService.updateEngagementTaskStatus(task.id, EngagementTaskStatus.DISTRIBUTED) + } + catch (error) { + this.logger.error(`[task-${task.id}] Failed to distribute comments task: ${error.message}`) + const status = taskPublishedCount > 0 ? EngagementTaskStatus.DISTRIBUTED : EngagementTaskStatus.FAILED + await this.engagementRecordService.updateEngagementTaskStatus(task.id, status) + } + } + + private async distributeAllCommentsTask(task: EngagementTask) { + let taskPublishedCount = 0 + try { + let pagination: KeysetPagination | OffsetPagination | null = null + while (true) { + const req: FetchPostCommentsRequest = { + accountId: task.accountId, + postId: task.postId, + platform: task.platform as any, + pagination, + } + const resp = await this.engagementService.fetchPostComments(req) + if (resp.comments.length === 0) { + break + } + const comments: Comment[] = [] + for (const comment of resp.comments) { + comments.push({ id: comment.id, comment: comment.message }) + } + const data: AIGenCommentDto = { + userId: task.userId, + comments, + model: task.model, + prompt: task.prompt || '', + } + const aiResp = await this.engagementService.batchGenReplyContent(data) + for (const comment of resp.comments) { + const replyContent = aiResp[comment.id] + const existingSubTasks = await this.engagementRecordService.searchEngagementSubTasksByCommentId(task.postId, comment.id, EngagementTaskStatus.COMPLETED) + if (existingSubTasks && existingSubTasks.length > 0) { + this.logger.warn(`[task-${task.id}] Skip creating sub-task for comment ${comment.id} as it already has a completed sub-task.`) + continue + } + const subTask = await this.engagementRecordService.createEngagementSubTask({ + accountId: task.accountId, + userId: task.userId, + postId: task.postId, + platform: task.platform, + taskType: EngagementTaskType.REPLY, + taskId: task.id, + commentId: comment.id, + commentContent: comment.message, + status: EngagementTaskStatus.CREATED, + replyContent, + }) + await this.publish(subTask) + taskPublishedCount += 1 + } + await this.engagementRecordService.incrementEngagementTaskTotalSubTasks(task.id, resp.comments.length) + pagination = resp.cursor + if (pagination && pagination.before) { + pagination.before = '' + } + } + await this.engagementRecordService.updateEngagementTaskStatus(task.id, EngagementTaskStatus.DISTRIBUTED) + } + catch (error) { + this.logger.error(`[task-${task.id}] Failed to distribute comments task: ${error.message}`) + const status = taskPublishedCount > 0 ? EngagementTaskStatus.DISTRIBUTED : EngagementTaskStatus.FAILED + await this.engagementRecordService.updateEngagementTaskStatus(task.id, status) + } + } + + async process(job: Job<{ + taskId: string + attempts: number + }>): Promise { + const task = await this.engagementRecordService.getEngagementTask(job.data.taskId) + if (!task) { + this.logger.error(`[task-${job.data.taskId}] Engagement task not found: ${job.data.taskId}`) + return + } + this.logger.log(`[task-${job.data.taskId}] Processing Engagement Task: ${job.data.taskId} for platform ${task.platform}`) + try { + if (task.targetScope === 'PARTIAL' && task.targetIds && task.targetIds.length > 0) { + await this.distributePartialCommentsTask(task) + } + else if (task.targetScope === 'ALL') { + await this.distributeAllCommentsTask(task) + } + else { + this.logger.warn(`[task-${job.data.taskId}] No target IDs provided for PARTIAL scope task.`) + await this.engagementRecordService.updateEngagementTaskStatus(task.id, EngagementTaskStatus.FAILED) + } + } + catch (error) { + this.logger.error(`[task-${job.data.taskId}] Error processing job ${job.id}: ${error.message}`, error.stack) + throw new Error(`[task-${job.data.taskId}] Job ${job.id} failed: ${error.message}`) + } + } + + async onModuleDestroy() { + this.logger.log('EngagementTaskDistributionConsumer is being destroyed, closing worker...') + await this.worker.close() + this.logger.log('EngagementTaskDistributionConsumer closed successfully') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/reply-to-comment.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/reply-to-comment.consumer.ts new file mode 100644 index 000000000..e11bd447d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/engagement/workers/reply-to-comment.consumer.ts @@ -0,0 +1,66 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger, OnModuleDestroy } from '@nestjs/common' +import { QueueName } from '@yikart/aitoearn-queue' +import { Job } from 'bullmq' +import { EngagementTaskStatus } from '../../../libs/database/schema/engagement.task.schema' +import { EngagementProvider } from '../engagement.interface' +import { EngagementRecordService } from '../engagement.record.service' +import { FacebookEngagementProvider } from '../providers/facebook.provider' +import { InstagramEngagementProvider } from '../providers/instagram.provider' +import { ThreadsEngagementProvider } from '../providers/threads.provider' + +@Processor(QueueName.EngagementReplyToComment, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class EngagementReplyToCommentConsumer extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(EngagementReplyToCommentConsumer.name) + private readonly providerMap = new Map() + constructor( + facebookProvider: FacebookEngagementProvider, + instagramProvider: InstagramEngagementProvider, + threadsProvider: ThreadsEngagementProvider, + private readonly engagementRecordService: EngagementRecordService, + ) { + super() + this.providerMap.set('facebook', facebookProvider) + this.providerMap.set('instagram', instagramProvider) + this.providerMap.set('threads', threadsProvider) + } + + private getProvider(providerKey: string): EngagementProvider { + const provider = this.providerMap.get(providerKey) + if (!provider) { + throw new Error(`Engagement provider for ${providerKey} not found`) + } + return provider + } + + async process(job: Job<{ + taskId: string + attempts: number + }>): Promise { + const subTask = await this.engagementRecordService.getEngagementSubTask(job.data.taskId) + if (!subTask) { + this.logger.error(`Sub task ${job.data.taskId} not found`) + return + } + if (subTask.status === EngagementTaskStatus.CREATED) { + const provider = this.getProvider(subTask.platform) + const resp = await provider.replyToComment(subTask.accountId, subTask.commentId, subTask.replyContent) + const status = resp.success ? EngagementTaskStatus.COMPLETED : EngagementTaskStatus.FAILED + await this.engagementRecordService.updateEngagementSubTaskStatus(subTask.id, status) + this.logger.log(`Sub task ${subTask.id} processed with status ${status}`) + if (resp.success) { + await this.engagementRecordService.incrementEngagementTaskCompletedSubTasks(subTask.taskId, 1) + } + } + } + + async onModuleDestroy() { + this.logger.log('EngagementReplyToCommentConsumer is being destroyed, closing worker...') + await this.worker.close() + this.logger.log('EngagementReplyToCommentConsumer closed successfully') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/dto/file.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/dto/file.dto.ts new file mode 100644 index 000000000..2d9aff012 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/dto/file.dto.ts @@ -0,0 +1,36 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: 反馈 + */ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +const InitMultipartUploadSchema = z.object({ + fileName: z.string({ message: '文件名称' }), + secondPath: z.string({ message: '存放位置' }), + fileSize: z.string({ message: '文件大小' }), + contentType: z.string({ message: '文件类型' }), +}) +export class InitMultipartUploadDto extends createZodDto(InitMultipartUploadSchema) {} + +const UploadPartSchema = z.object({ + fileId: z.string({ message: '文件key' }), + uploadId: z.string({ message: '上传ID' }), + partNumber: z.number({ message: '分片索引' }), +}) +export class UploadPartDto extends createZodDto(UploadPartSchema) {} + +const CompletePartSchema = z.object({ + fileId: z.string({ message: '文件key' }), + uploadId: z.string({ message: '上传ID' }), + parts: z.array( + z.object({ + PartNumber: z.number(), + ETag: z.string(), + }), + ).describe('分片'), +}) +export class CompletePartDto extends createZodDto(CompletePartSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.module.ts new file mode 100644 index 000000000..f019c4d23 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.module.ts @@ -0,0 +1,21 @@ +/* + * @Author: nevin + * @Date: 2022-03-03 16:50:53 + * @LastEditors: nevin + * @LastEditTime: 2024-06-24 17:48:23 + * @Description: 文件存储 + */ +import { Global, Module } from '@nestjs/common' +import { config } from '../../config' +import { S3Module } from '../../libs/aws-s3/s3.module' +import { FileService } from './file.service' + +@Global() +@Module({ + imports: [ + S3Module.forRoot(config.awsS3), + ], + providers: [FileService], + exports: [FileService], +}) +export class FileModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.service.ts new file mode 100644 index 000000000..f1740be1e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/file/file.service.ts @@ -0,0 +1,194 @@ +import { Injectable } from '@nestjs/common' +import * as mime from 'mime-types' +import moment from 'moment' +import { v4 as uuidv4 } from 'uuid' +import { config } from '../../config' +import { S3Service } from '../../libs/aws-s3/s3.service' + +@Injectable() +export class FileService { + constructor(private readonly s3Service: S3Service) {} + + private getNewFilePath(opt: { + path: string + newName?: string + permanent?: boolean + }) { + let { path, newName } = opt + + path = `${opt.permanent ? '' : 'temp/'}${path || `nopath/${moment().format('YYYYMM')}`}` + path = path.replace('//', '/') + newName = newName || uuidv4() + + return { + path, + newName, + } + } + + /** + * 文件上传 + * @param {Express.Multer.File} file 文件buffer流对象 + * @param {string | undefined} path 路径,不传就会使用‘nopath’前缀 + * @param {string | undefined} newName 新的文件名 + * @param {string | undefined} permanent 是否为永久目录,默认临时 + * @returns + */ + async upFileStream( + file: any, + path: string, + newName?: string, + permanent?: boolean, + ) { + const { path: newPath, newName: newFileName } = this.getNewFilePath({ + path, + newName, + permanent, + }) + const filePath = `${newPath}/${newFileName}.${mime.extension(file.mimetype)}` + const res = await this.s3Service.uploadFile( + filePath, + file.buffer, + file.mimetype, + ) + + return res + } + + /** + * 上传二进制流文件 + * @param buffer 二进制流 base64格式 + * @param option + * @param option.path 路径 + * @param option.permanent 是否为永久目录,默认临时 + * @param option.fileType 文件后缀 + */ + async uploadByStream( + buffer: Buffer, // base64格式(不带前缀) + option: { + path?: string + permanent?: boolean + fileType: string + }, + ): Promise { + const { path, permanent, fileType } = option + const objectName = `${permanent ? '' : 'temp/'}${path || 'nopath'}${`/${moment().format('YYYYMM')}/${uuidv4()}.${fileType}`}` + const res = await this.s3Service.uploadFile( + objectName, + buffer, + `application/${fileType}`, + ) + + return res.key + } + + /** + * aws 根据URL上传文件 + * @param url 远程地址 + * @param option + * @returns + */ + async upFileByUrl( + url: string, + option: { path?: string, permanent?: boolean }, + ): Promise { + const { path, permanent } = option + // 从URL下载文件内容 + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to download file from URL: ${url}`) + } + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + // 根据响应内容类型获取文件后缀 + const contentType = response.headers.get('content-type') || 'application/octet-stream' + const fileType = mime.extension(contentType) || 'jpg' + const objectName = `${permanent ? '' : 'temp/'}${path || 'nopath'}${`/${moment().format('YYYYMM')}/${uuidv4()}.${fileType}`}` + + const res = await this.s3Service.uploadFile( + objectName, + buffer, + contentType, + ) + + return res.key + } + + /** + * 初始化分片 + * @param path + * @param fileType + * @returns + */ + async initiateMultipartUpload(path: string, fileType: string) { + const { path: newPath, newName: newFileName } = this.getNewFilePath({ + path, + }) + const filePath = `${newPath}/${newFileName}.${mime.extension(fileType)}` + const res = await this.s3Service.initiateMultipartUpload(filePath) + return { + uploadId: res, + fileId: filePath, + } + } + + /** + * 上传分片数据 + * @param fileId + * @param uploadId + * @param partNumber + * @param partData + * @returns + */ + async uploadPart( + fileId: string, + uploadId: string, + partNumber: number, + partData: Buffer, + ) { + const res = await this.s3Service.uploadPart( + fileId, + uploadId, + partNumber, + partData, + ) + + return { + PartNumber: partNumber, + ETag: res.ETag, + } + } + + /** + * 合并分片 + * @param key + * @param uploadId + * @param parts + * @returns + */ + async completeMultipartUpload( + key: string, + uploadId: string, + parts: { PartNumber: number, ETag: string }[], + ) { + const res = await this.s3Service.completeMultipartUpload( + key, + uploadId, + parts, + ) + + return res + } + + /** + * 文件路径转换为url + * @param url 参考标题 + * @returns + */ + filePathToUrl(url: string): string { + if (url.startsWith('http')) + return url + return `${config.awsS3.hostUrl}/${url}` + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/index.ts new file mode 100644 index 000000000..a119170c3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/index.ts @@ -0,0 +1 @@ +export * from './core.module' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interact.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interact.dto.ts new file mode 100644 index 000000000..ceaafb1fd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interact.dto.ts @@ -0,0 +1,31 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const ReplyCommentSchema = z.object({ + accountId: z.string().describe('账号ID'), + commentId: z.string().describe('评论ID'), + content: z.string().describe('内容'), +}) +export class ReplyCommentDto extends createZodDto(ReplyCommentSchema) {} + +export const DelCommentSchema = z.object({ + accountId: z.string().describe('账号ID'), + commentId: z.string().describe('评论ID'), +}) +export class DelCommentDto extends createZodDto(DelCommentSchema) {} + +export const AddArcCommentSchema = z.object({ + accountId: z.string().describe('账号ID'), + dataId: z.string().describe('dataId不能为空'), + content: z.string().describe('内容'), +}) +export class AddArcCommentDto extends createZodDto(AddArcCommentSchema) {} + +export const GetArcCommentListSchema = z.object({ + recordId: z.string().describe('记录ID'), + pageNo: z.number().describe('页码'), + pageSize: z.number().describe('每页数据'), +}) +export class GetArcCommentListDto extends createZodDto( + GetArcCommentListSchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interactionRecord.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interactionRecord.dto.ts new file mode 100644 index 000000000..9bbd939eb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/interactionRecord.dto.ts @@ -0,0 +1,36 @@ +import { AccountType } from '@yikart/aitoearn-server-client' +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const AddInteractionRecordSchema = z.object({ + userId: z.string().min(1, { message: '用户ID不能为空' }), + accountId: z.string().min(1, { message: '账号ID不能为空' }), + type: z.enum(AccountType, { message: '账号类型不能为空' }), + worksId: z.string().min(1, { message: '作品ID不能为空' }), + worksTitle: z.string().optional(), + worksCover: z.string().optional(), + worksContent: z.string().optional(), + commentContent: z.string().optional(), + commentRemark: z.string().optional(), + commentTime: z.string().optional(), + likeTime: z.string().optional(), + collectTime: z.string().optional(), +}) +export class AddInteractionRecordDto extends createZodDto(AddInteractionRecordSchema) {} + +export const InteractionRecordFiltersSchema = z.object({ + userId: z.string().min(1, { message: '用户ID不能为空' }), + accountId: z.string().optional(), + type: z.enum(AccountType).optional(), + worksId: z.string().optional(), + time: z.tuple([z.date(), z.date()]).optional(), +}) + +export const InteractionRecordListSchema = z.object({ + filters: InteractionRecordFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class InteractionRecordListDto extends createZodDto(InteractionRecordListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/replyCommentRecord.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/replyCommentRecord.dto.ts new file mode 100644 index 000000000..1cd0cc941 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/dto/replyCommentRecord.dto.ts @@ -0,0 +1,30 @@ +import { AccountType } from '@yikart/aitoearn-server-client' +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const AddReplyCommentRecordSchema = z.object({ + userId: z.string().min(1, { message: '用户ID不能为空' }), + accountId: z.string().min(1, { message: '账号ID不能为空' }), + type: z.enum(AccountType, { message: '账号类型不能为空' }), + commentId: z.string().min(1, { message: '评论ID不能为空' }), + commentContent: z.string().min(1, { message: '评论内容不能为空' }), + replyContent: z.string().min(1, { message: '回复内容不能为空' }), +}) +export class AddReplyCommentRecordDto extends createZodDto(AddReplyCommentRecordSchema) {} + +export const ReplyCommentRecordFiltersSchema = z.object({ + userId: z.string().min(1, { message: '用户ID不能为空' }), + accountId: z.string().optional(), + type: z.enum(AccountType).optional(), + commentId: z.string().optional(), + time: z.tuple([z.date(), z.date()]).optional(), +}) + +export const ReplyCommentRecordListSchema = z.object({ + filters: ReplyCommentRecordFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class ReplyCommentRecordListDto extends createZodDto(ReplyCommentRecordListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.base.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.base.ts new file mode 100644 index 000000000..352ed8318 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.base.ts @@ -0,0 +1,30 @@ +import { PublishRecord } from '@yikart/aitoearn-server-client' +import { Account } from '../../libs/database/schema/account.schema' + +export abstract class InteracteBase { + // 添加作品评论 + abstract addArcComment( + account: Account, + dataId: string, + content: string, + ): Promise + + // 获取作品的评论列表 + abstract getArcCommentList( + publishRecord: PublishRecord, + query: { + pageNo: number + pageSize: number + }, + ): Promise<{ list: any[], total: number }> + + // 回复评论 + abstract replyComment( + accountId: string, + commentId: string, + content: string, + ): Promise + + // 删除评论 + abstract delComment(accountId: string, commentId: string): Promise +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.controller.ts new file mode 100644 index 000000000..8e669aa48 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.controller.ts @@ -0,0 +1,92 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 渠道互动 + */ +import { Body, Controller, Post } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { AccountService } from '../account/account.service' +import { PublishRecordService } from '../account/publishRecord.service' +import { + AddArcCommentDto, + DelCommentDto, + GetArcCommentListDto, + ReplyCommentDto, +} from './dto/interact.dto' +import { InteracteBase } from './interact.base' +import { WxGzhInteractService } from './wxGzhInteract.service' + +@Controller() +export class InteracteController { + private readonly interactMap = new Map() + + constructor( + readonly accountService: AccountService, + readonly publishRecordService: PublishRecordService, + readonly wxGzhInteractService: WxGzhInteractService, + ) { + this.interactMap.set(AccountType.BILIBILI, wxGzhInteractService) + } + + private async getInteract(accountId: string) { + const account = await this.accountService.getAccountInfo(accountId) + if (!account) + throw new AppException(1, '账户不存在') + const interact = this.interactMap.get(account.type) + if (!interact) + throw new AppException(1, '暂不支持该账户类型') + return { interact, account } + } + + // @NatsMessagePattern('channel.interact.addArcComment') + @Post('channel/interact/addArcComment') + async getAccountDataCube(@Body() data: AddArcCommentDto) { + const interact = await this.getInteract(data.accountId) + const res = await interact.interact.addArcComment( + interact.account, + data.dataId, + data.content, + ) + return res + } + + // @NatsMessagePattern('channel.interact.getArcCommentList') + @Post('channel/interact/getArcCommentList') + async getAccountDataBulk(@Body() data: GetArcCommentListDto) { + const record = await this.publishRecordService.getPublishRecordInfo( + data.recordId, + ) + if (!record) { + throw new AppException(1, '未找到发布记录') + } + const interact = await this.getInteract(record.accountId) + const res = await interact.interact.getArcCommentList(record, data) + return res + } + + // @NatsMessagePattern('channel.interact.replyComment') + @Post('channel/interact/replyComment') + async replyComment(@Body() data: ReplyCommentDto) { + const interact = await this.getInteract(data.accountId) + const res = await interact.interact.replyComment( + data.accountId, + data.commentId, + data.content, + ) + return res + } + + // @NatsMessagePattern('channel.interact.delComment') + @Post('channel/interact/delComment') + async getArcDataBulk(@Body() data: DelCommentDto) { + const interact = await this.getInteract(data.accountId) + const res = await interact.interact.delComment( + data.accountId, + data.commentId, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.module.ts new file mode 100644 index 000000000..7f7234b96 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interact.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { InteractionRecord, InteractionRecordSchema } from '../../libs/database/schema/interactionRecord.schema' +import { ReplyCommentRecord, ReplyCommentRecordSchema } from '../../libs/database/schema/replyCommentRecord.schema' +import { WxPlatModule } from '../plat/wxPlat/wxPlat.module' +import { PublishModule } from '../publish/publish.module' +import { InteracteController } from './interact.controller' +import { InteractionRecordController } from './interactionRecord.controller' +import { InteractionRecordService } from './interactionRecord.service' +import { ReplyCommentRecordController } from './replyCommentRecord.controller' +import { ReplyCommentRecordService } from './replyCommentRecord.service' +import { WxGzhInteractService } from './wxGzhInteract.service' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: InteractionRecord.name, schema: InteractionRecordSchema }, + { name: ReplyCommentRecord.name, schema: ReplyCommentRecordSchema }, + ]), + WxPlatModule, + PublishModule, + ], + controllers: [InteracteController, InteractionRecordController, ReplyCommentRecordController], + providers: [WxGzhInteractService, InteractionRecordService, ReplyCommentRecordService], + exports: [WxGzhInteractService], +}) +export class InteracteModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.controller.ts new file mode 100644 index 000000000..42ca104ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.controller.ts @@ -0,0 +1,37 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 渠道互动记录 + */ +import { Body, Controller, Post } from '@nestjs/common' +import { AddInteractionRecordDto, InteractionRecordListDto } from './dto/interactionRecord.dto' +import { InteractionRecordService } from './interactionRecord.service' + +@Controller() +export class InteractionRecordController { + constructor( + readonly interactionRecordService: InteractionRecordService, + ) { + } + + // @NatsMessagePattern('channel.interactionRecord.add') + @Post('channel/interactionRecord/add') + async add(@Body() data: AddInteractionRecordDto) { + return await this.interactionRecordService.create(data) + } + + // @NatsMessagePattern('channel.interactionRecord.del') + @Post('channel/interactionRecord/del') + async del(@Body() data: { id: string }) { + return await this.interactionRecordService.delete(data.id) + } + + // @NatsMessagePattern('channel.interactionRecord.list') + @Post('channel/interactionRecord/list') + async list(@Body() data: InteractionRecordListDto) { + const res = await this.interactionRecordService.getList(data.filters, data.page) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.service.ts new file mode 100644 index 000000000..551265bb9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/interactionRecord.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountType } from '@yikart/aitoearn-server-client' +import { Model, RootFilterQuery } from 'mongoose' +import { TableDto } from '../../common/global/dto/table.dto' +import { InteractionRecord } from '../../libs/database/schema/interactionRecord.schema' +import { AddInteractionRecordDto } from './dto/interactionRecord.dto' + +@Injectable() +export class InteractionRecordService { + constructor( + @InjectModel(InteractionRecord.name) + private readonly interactionRecordModel: Model, + ) {} + + async create(data: AddInteractionRecordDto): Promise { + const createdRecord = new this.interactionRecordModel(data) + return createdRecord.save() + } + + async getList( + filters: { + userId: string + accountId?: string + type?: AccountType + worksId?: string + time?: [Date?, Date?, ...unknown[]] + }, + page: TableDto, + ): Promise<{ + total: number + list: InteractionRecord[] + }> { + const filter: RootFilterQuery = { + userId: filters.userId, + ...(filters.time && filters.time.length === 2 && { + createdAt: { $gte: filters.time[0], $lte: filters.time[1] }, + }), + ...(filters.accountId && { accountId: filters.accountId }), + ...(filters.type && { type: filters.type }), + ...(filters.worksId && { worksId: filters.worksId }), + } + const list = await this.interactionRecordModel + .find(filter) + .sort({ createdAt: -1 }) + .skip(((page.pageNo || 1) - 1) * page.pageSize) + .limit(page.pageSize) + .exec() + + return { + total: await this.interactionRecordModel.countDocuments(filter), + list, + } + } + + // 删除 + async delete(id: string): Promise<{ deleted: boolean }> { + const result = await this.interactionRecordModel.deleteOne({ _id: id }).exec() + return { deleted: result.deletedCount > 0 } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.controller.ts new file mode 100644 index 000000000..bc3febc49 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.controller.ts @@ -0,0 +1,37 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 评论回复记录 replyCommentRecord ReplyCommentRecord + */ +import { Body, Controller, Post } from '@nestjs/common' +import { AddReplyCommentRecordDto, ReplyCommentRecordListDto } from './dto/replyCommentRecord.dto' +import { ReplyCommentRecordService } from './replyCommentRecord.service' + +@Controller() +export class ReplyCommentRecordController { + constructor( + readonly replyCommentRecordService: ReplyCommentRecordService, + ) { + } + + // @NatsMessagePattern('channel.replyCommentRecord.add') + @Post('channel/replyCommentRecord/add') + async add(@Body() data: AddReplyCommentRecordDto) { + return await this.replyCommentRecordService.create(data) + } + + // @NatsMessagePattern('channel.replyCommentRecord.del') + @Post('channel/replyCommentRecord/del') + async del(@Body() data: { id: string }) { + return await this.replyCommentRecordService.delete(data.id) + } + + // @NatsMessagePattern('channel.replyCommentRecord.list') + @Post('channel/replyCommentRecord/list') + async list(@Body() data: ReplyCommentRecordListDto) { + const res = await this.replyCommentRecordService.getList(data.filters, data.page) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.service.ts new file mode 100644 index 000000000..eb30ac40c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/replyCommentRecord.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountType } from '@yikart/aitoearn-server-client' +import { Model, RootFilterQuery } from 'mongoose' +import { TableDto } from '../../common/global/dto/table.dto' +import { ReplyCommentRecord } from '../../libs/database/schema/replyCommentRecord.schema' +import { AddReplyCommentRecordDto } from './dto/replyCommentRecord.dto' + +@Injectable() +export class ReplyCommentRecordService { + constructor( + @InjectModel(ReplyCommentRecord.name) + private readonly replyCommentRecordModel: Model, + ) {} + + async create(data: AddReplyCommentRecordDto): Promise { + const createdRecord = new this.replyCommentRecordModel(data) + return createdRecord.save() + } + + async getList( + filters: { + userId: string + accountId?: string + type?: AccountType + worksId?: string + time?: [Date?, Date?, ...unknown[]] + }, + page: TableDto, + ): Promise<{ + total: number + list: ReplyCommentRecord[] + }> { + const filter: RootFilterQuery = { + userId: filters.userId, + ...(filters.time && filters.time.length === 2 && { + createdAt: { $gte: filters.time[0], $lte: filters.time[1] }, + }), + ...(filters.accountId && { accountId: filters.accountId }), + ...(filters.type && { type: filters.type }), + ...(filters.worksId && { worksId: filters.worksId }), + } + const list = await this.replyCommentRecordModel + .find(filter) + .skip(((page.pageNo || 1) - 1) * page.pageSize) + .limit(page.pageSize) + .exec() + + return { + total: await this.replyCommentRecordModel.countDocuments(filter), + list, + } + } + + // 删除 + async delete(id: string): Promise { + const result = await this.replyCommentRecordModel.deleteOne({ _id: id }).exec() + return result.deletedCount > 0 + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/wxGzhInteract.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/wxGzhInteract.service.ts new file mode 100644 index 000000000..58af584f2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/interact/wxGzhInteract.service.ts @@ -0,0 +1,56 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: 微信公众号-交互 + */ +import { Injectable, Logger } from '@nestjs/common' +import { PublishRecord } from '@yikart/aitoearn-server-client' +import { Account } from '../../libs/database/schema/account.schema' +import { WxGzhService } from '../plat/wxPlat/wxGzh.service' +import { InteracteBase } from './interact.base' + +@Injectable() +export class WxGzhInteractService extends InteracteBase { + private readonly logger = new Logger(WxGzhInteractService.name) + constructor(readonly wxGzhService: WxGzhService) { + super() + } + + /** + * 创建文章评论 + * @param account + * @param dataId + * @param content + * @returns + */ + async addArcComment(account: Account, dataId: string, content: string) { + this.logger.log('addArcComment', account.id, dataId, content) + return true + } + + async getArcCommentList( + publishRecord: PublishRecord, + query: { + pageNo: number + pageSize: number + }, + ) { + this.logger.log('getArcCommentList', publishRecord, query) + return { + list: [], + total: 0, + } + } + + async replyComment(accountId: string, commentId: string, content: string) { + this.logger.log('replyComment', commentId, content) + return true + } + + async delComment(accountId: string, commentId: string) { + this.logger.log('delComment', commentId) + return true + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/bilibili.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/bilibili.controller.ts new file mode 100644 index 000000000..3d57ddfa7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/bilibili.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, HttpCode, Param, UseGuards } from '@nestjs/common' +import { SkKeyAuthGuard } from '../../common/guards/skKeyAuth.guard' +import { BilibiliService } from '../plat/bilibili/bilibili.service' + +@Controller() +export class BilibiliController { + constructor(private readonly bilibiliService: BilibiliService) {} + + @HttpCode(200) + @UseGuards(SkKeyAuthGuard) + @Get('archiveTypeList/:accountId') + async publishRecordList(@Param('accountId') accountId: string) { + const res = await this.bilibiliService.archiveTypeList(accountId) + + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/account.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/account.dto.ts new file mode 100644 index 000000000..524eacf8d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/account.dto.ts @@ -0,0 +1,17 @@ +import { AccountType } from '@yikart/aitoearn-server-client' +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const GetAccountInfoSchema = z.object({ + id: z.string().describe('账号ID'), +}) +export class GetAccountInfoDto extends createZodDto( + GetAccountInfoSchema, +) {} + +export const GetAccountListQuerySchema = z.object({ + type: z.enum(AccountType).optional(), +}) +export class GetAccountListQueryDto extends createZodDto( + GetAccountListQuerySchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/mcp.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/mcp.dto.ts new file mode 100644 index 000000000..1d49ac6bc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/mcp.dto.ts @@ -0,0 +1,61 @@ +import { AccountType } from '@yikart/aitoearn-server-client' +import { z } from 'zod/v3' +import { PublishType } from '../../../libs/database/schema/publishTask.schema' + +export const GetAuthPageSchema = z.object({ + accountType: z.nativeEnum(AccountType).describe('平台类型'), +}) + +export const McpPublishSchema = z.object({ + skKey: z.string().describe('skKey'), + type: z.nativeEnum(PublishType).describe('类型'), + title: z.string().nullable().optional().transform(val => !val ? undefined : val), + desc: z.string().nullable().optional().transform(val => !val ? undefined : val), + videoUrl: z.string().nullable().optional().transform(val => !val ? undefined : val), + coverUrl: z.string().nullable().optional().transform(val => !val ? undefined : val), + imgUrlList: z.string().nullable().optional().transform(val => !val ? undefined : val), + publishTime: z.string().nullable().optional().transform(val => !val ? undefined : val), + topics: z.string(), +}) + +export const McpPromptPublishSchema = z.object({ + id: z.string(), + userId: z.string(), +}) + +export const UpdatePublishTaskTimeSchema = z.object({ + id: z.string().describe('publishing task ID'), + publishingTime: z.date().default(() => new Date()), + userId: z.string(), +}) + +export const McpAuthedAccountSchema = z.object({ + accountId: z.string().describe('accountId'), + userId: z.string().describe('userId'), + platform: z.nativeEnum(AccountType).describe('平台类型'), + nickname: z.string().nullable().optional().describe('昵称'), +}) + +export const McpAuthedAccountsResponseSchema = z.object({ + accounts: z.array(McpAuthedAccountSchema).describe('已授权账号列表'), +}) + +export const CreatePublishingTaskRespItemSchema = z.object({ + id: z.string().describe('任务ID'), +}).describe('创建的发布任务信息') + +export const CreatePublishingTaskRespSchema = z.object({ + tasks: z.array(CreatePublishingTaskRespItemSchema).describe('创建的发布任务列表'), +}) + +export const UpdatePublishingTimeRespSchema = z.object({ + id: z.string().describe('任务ID'), + publishTime: z.string().describe('新的发布时间'), +}).describe('更新发布任务时间响应') + +export type McpPublishDto = z.infer +export type UpdatePublishTaskTimeDto = z.infer +export type McpAuthedAccountVo = z.infer +export type McpAuthedAccountsRespVo = z.infer +export type CreatePublishingTaskResp = z.infer +export type UpdatePublishingTimeResp = z.infer diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/publish.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/publish.dto.ts new file mode 100644 index 000000000..d21145c9b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/dto/publish.dto.ts @@ -0,0 +1,31 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { PublishType } from '../../../libs/database/schema/publishTask.schema' + +export const CreatePublishingTaskSchema = z.object({ + accounts: z.array(z.string()).describe('账户ID数组').optional(), + mediaType: z.enum(PublishType).describe('类型'), + title: z.string().nullish().describe('标题'), + desc: z.string().nullish().describe('内容'), + videoUrl: z.string().nullish().describe('视频链接'), + coverUrl: z.string().nullish().describe('封面链接'), + imgUrlList: z.string().nullish().describe('图片链接数组,逗号分隔'), + publishingTime: z.string().nullish().describe('发布时间,格式:YYYY-MM-DD HH:mm:ss'), + topics: z.string(), +}) + +export const CreatePublishSchema = z.object({ + flowId: z.string().describe('流水ID').nullable().optional().transform(val => !val ? undefined : val), + accountId: z.string().describe('账户ID'), + type: z.enum(PublishType).describe('类型'), + title: z.string().nullable().optional().transform(val => !val ? undefined : val), + desc: z.string().nullable().optional().transform(val => !val ? undefined : val), + videoUrl: z.string().nullable().optional().transform(val => !val ? undefined : val), + coverUrl: z.string().nullable().optional().transform(val => !val ? undefined : val), + imgUrlList: z.string().nullable().optional().transform(val => !val ? undefined : val), + publishTime: z.string().nullable().optional().transform(val => !val ? undefined : val), + topics: z.string(), +}) + +export class CreatePublishDto extends createZodDto(CreatePublishSchema) {} +export class CreatePublishingTaskDto extends createZodDto(CreatePublishingTaskSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.controller.ts new file mode 100644 index 000000000..b42afa4bb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.controller.ts @@ -0,0 +1,64 @@ +import { Controller } from '@nestjs/common' +import { Prompt, PromptOptions, Tool, ToolOptions } from '@rekog/mcp-nest' +import { AppException } from '@yikart/common' +import { Request } from 'express' +import { z } from 'zod' +import { ExceptionCode } from '../../common/enums/exception-code.enum' +import { CreatePublishingTaskRespSchema, McpAuthedAccountsResponseSchema, McpPromptPublishSchema, UpdatePublishingTimeRespSchema, UpdatePublishTaskTimeDto, UpdatePublishTaskTimeSchema } from './dto/mcp.dto' +import { CreatePublishingTaskDto, CreatePublishingTaskSchema } from './dto/publish.dto' +import { McpService } from './mcp.service' + +@Controller() +export class McpController { + constructor( + private readonly mcpService: McpService, + ) { } + + @Prompt({ + name: 'generate_publishing_prompt', + description: 'Generate a publishing prompt', + parameters: McpPromptPublishSchema as unknown as PromptOptions['parameters'], + }) + async generatePublishingPrompt() { + return this.mcpService.generatePublishingPrompt() + } + + @Tool({ + name: 'list_linked_accounts', + description: 'List linked accounts', + parameters: z.object({}).describe('无参数'), + outputSchema: McpAuthedAccountsResponseSchema as unknown as ToolOptions['outputSchema'], + }) + async listLinkedAccounts(request: Request) { + const skKey = request.headers['sk-key'] || request.query['sk-key'] + if (!skKey || typeof skKey !== 'string') { + throw new AppException(ExceptionCode.Failed, 'skKey is required in header or query') + } + return await this.mcpService.listLinkedAccounts(skKey) + } + + @Tool({ + name: 'create_publishing_task', + description: 'Create a publishing task', + parameters: CreatePublishingTaskSchema, + outputSchema: CreatePublishingTaskRespSchema as unknown as ToolOptions['outputSchema'], + }) + async createPublishingTask(data: CreatePublishingTaskDto, request: Request) { + const skKey = request.headers['sk-key'] || request.query['sk-key'] + if (!skKey || typeof skKey !== 'string') { + throw new AppException(ExceptionCode.Failed, 'skKey is required in header or query') + } + return await this.mcpService.bulkCreatePublishingTask(skKey, data) + } + + @Tool({ + name: 'update_publishing_time', + description: 'update publishing time', + parameters: UpdatePublishTaskTimeSchema as unknown as ToolOptions['parameters'], + outputSchema: UpdatePublishingTimeRespSchema as unknown as ToolOptions['outputSchema'], + }) + async changeTaskTime(data: UpdatePublishTaskTimeDto) { + const res = await this.mcpService.updatePublishingTime(data) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.module.ts new file mode 100644 index 000000000..1c0565b5f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { SkKey, SkKeySchema } from '../../libs/database/schema/skKey.schema' +import { + SkKeyRefAccount, + SkKeyRefAccountSchema, +} from '../../libs/database/schema/skKeyRefAccount.schema' +import { BilibiliModule } from '../plat/bilibili/bilibili.module' +import { PublishModule } from '../publish/publish.module' +import { BilibiliController } from './bilibili.controller' +import { McpController } from './mcp.controller' +import { McpService } from './mcp.service' +import { PluginController } from './plugin.controller' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SkKey.name, schema: SkKeySchema }, + { name: SkKeyRefAccount.name, schema: SkKeyRefAccountSchema }, + ]), + BilibiliModule, + PublishModule, + ], + providers: [McpService], + controllers: [McpController, PluginController, BilibiliController], + exports: [], +}) +export class McpModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.service.ts new file mode 100644 index 000000000..39de34120 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/mcp.service.ts @@ -0,0 +1,138 @@ +import { + GetPromptResult, +} from '@modelcontextprotocol/sdk/types' +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import moment from 'moment' +import { ExceptionCode } from '../../common/enums/exception-code.enum' +import { AccountService } from '../account/account.service' +import { PlatPulOption } from '../publish/common' +import { PublishTaskService } from '../publish/publishTask.service' +import { SkKeyService } from '../skKey/skKey.service' +import { CreatePublishingTaskResp, McpAuthedAccountsRespVo, McpAuthedAccountVo, UpdatePublishTaskTimeDto } from './dto/mcp.dto' +import { CreatePublishingTaskDto } from './dto/publish.dto' + +@Injectable() +export class McpService { + private readonly logger = new Logger(McpService.name) + constructor( + private readonly publishTaskService: PublishTaskService, + private readonly accountService: AccountService, + private readonly skKeyService: SkKeyService, + ) { } + + private async createPublishingTask(accountId: string, data: CreatePublishingTaskDto) { + const now = new Date() + const defaultPublishingTime = moment(now).add(2, 'minute').toDate() + const publishingTime = data.publishingTime ? moment(data.publishingTime).toDate() : defaultPublishingTime + if (publishingTime < now) { + throw new AppException(1, 'publishingTime cannot be less than the current time') + } + + const accountInfo = await this.accountService.getAccountInfo(accountId) + if (!accountInfo) + throw new AppException(ExceptionCode.UserNotFound, 'Account not found') + + const { imgUrlList, topics } = data + + const option: PlatPulOption = { + bilibili: { + tid: 160, + copyright: 1, + }, + } + const task = await this.publishTaskService.createPub({ + inQueue: false, + queueId: '', + accountId, + type: data.mediaType, + uid: accountInfo.uid, + userId: accountInfo.userId, + accountType: accountInfo.type, + title: data.title || '', + desc: data.desc || '', + videoUrl: data.videoUrl || '', + coverUrl: data.coverUrl || '', + option, + publishTime: publishingTime, + imgUrlList: imgUrlList?.split(','), + topics: topics?.split(','), + }) + return task.id + } + + async bulkCreatePublishingTask(skKey: string, data: CreatePublishingTaskDto): Promise { + const now = new Date() + const defaultPublishingTime = moment(now).add(2, 'minute').toDate() + const publishingTime = data.publishingTime ? moment(data.publishingTime).toDate() : defaultPublishingTime + if (publishingTime < now) { + throw new AppException(1, 'publishingTime cannot be less than the current time') + } + let accountIdList = data.accounts + if (!accountIdList || accountIdList.length === 0) { + const accounts = await this.skKeyService.getRefAccountAll(skKey) + accountIdList = accounts.map(acc => acc.accountId) + } + if (accountIdList.length === 0) { + throw new AppException(ExceptionCode.UserNotFound, 'No accounts found for the provided skKey') + } + const resp: CreatePublishingTaskResp = { + tasks: [], + } + for (const accountId of accountIdList) { + const taskId = await this.createPublishingTask(accountId, data) + resp.tasks.push(taskId) + } + return resp + } + + async generatePublishingPrompt(): Promise { + const res: GetPromptResult = { + role: 'assistant', + content: { + type: 'text', + text: `平台账号类型: 微信公众号:${AccountType.WxGzh},bilibili:${AccountType.BILIBILI},抖音: ${AccountType.Douyin},快手:${AccountType.KWAI},twitter:${AccountType.TWITTER},instagram:${AccountType.INSTAGRAM},threads:${AccountType.THREADS},youtube:${AccountType.YOUTUBE}}`, + }, + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `帮我写一个发布任务,内容是:desc,标题是:title, 类型是:type,视频链接是:videoUrl,封面链接地址是:coverUrl,图片链接地址数组是:imgUrlList,发布时间是:publishTime, 话题数组是:topics `, + }, + }, + ], + } + return res + } + + async updatePublishingTime(data: UpdatePublishTaskTimeDto) { + await this.publishTaskService.updatePublishTaskTime( + data.id, + data.publishingTime, + data.userId, + ) + return { + id: data.id, + publishTime: data.publishingTime, + } + } + + async listLinkedAccounts(skKey: string): Promise { + const authedAccounts = await this.skKeyService.getRefAccountAll(skKey) + const accounts: McpAuthedAccountVo[] = [] + for (const acc of authedAccounts) { + const accountInfo = await this.accountService.getAccountInfo(acc.accountId) + if (accountInfo) { + accounts.push({ + accountId: accountInfo.id, + userId: accountInfo.userId, + platform: accountInfo.type, + nickname: accountInfo.nickname || '', + }) + } + } + return { accounts } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/plugin.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/plugin.controller.ts new file mode 100644 index 000000000..f79251ed4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/mcp/plugin.controller.ts @@ -0,0 +1,154 @@ +import { Body, Controller, Get, HttpCode, Logger, Param, Post, UseGuards } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { plainToInstance } from 'class-transformer' +import moment from 'moment' +import { ExceptionCode } from '../../common/enums/exception-code.enum' +import { GetSkKey, SkKeyAuthGuard } from '../../common/guards/skKeyAuth.guard' +import { PublishType } from '../../libs/database/schema/publishTask.schema' +import { SkKey } from '../../libs/database/schema/skKey.schema' +import { AccountService } from '../account/account.service' +import { PublishTaskService } from '../publish/publishTask.service' +import { SkKeyService } from '../skKey/skKey.service' +import { CreatePublishDto } from './dto/publish.dto' + +@Controller() +export class PluginController { + logger = new Logger(PluginController.name) + constructor( + private readonly accountService: AccountService, + private readonly skKeyService: SkKeyService, + private readonly publishTaskService: PublishTaskService, + ) { } + + /** + * 获取key的账号列表 + * @param body + * @returns + */ + @HttpCode(200) + @UseGuards(SkKeyAuthGuard) + @Get('account/list') + async accountList(@GetSkKey() skKey: SkKey) { + const list = await this.skKeyService.getRefAccountAll(skKey.key) + return list + } + + /** + * 创建发布 + * @param body + * @returns + */ + @HttpCode(200) + @UseGuards(SkKeyAuthGuard) + @Post('publish/create') + async createPub(@Body() body: CreatePublishDto) { + try { + body = plainToInstance(CreatePublishDto, body) + // 发布时间处理 + let publishTimeDate: Date = new Date(Date.now() + 2 * 60 * 1000) + + const { publishTime } = body + + // 如果publishTime为空,或者转换时间有误,则使用publishTimeDate + if (!publishTime || !moment(publishTime).isValid()) { + publishTimeDate = new Date(Date.now() + 2 * 60 * 1000) + } + else { + publishTimeDate = new Date(publishTime) + } + + const accountInfo = await this.accountService.getAccountInfo( + body.accountId, + ) + if (!accountInfo) { + throw new AppException(ExceptionCode.Failed, '账号信息获取失败') + } + const { imgUrlList, topics } = body + + // B站默认值 + if (accountInfo.type === AccountType.BILIBILI) { + (body as any).option = { + bilibili: { + tid: 160, + copyright: 1, + }, + } + } + + if (accountInfo.type === AccountType.FACEBOOK) { + (body as any).option = { + facebook: { + content_category: 'post', + }, + } + } + + if (accountInfo.type === AccountType.INSTAGRAM) { + const contentCategory = body.type === PublishType.VIDEO ? 'reel' : 'post'; + (body as any).option = { + instagram: { + content_category: contentCategory, // post、reel、story + }, + } + } + + if (accountInfo.type === AccountType.YOUTUBE) { + (body as any).option = { + youtube: { + categoryId: '43', + }, + } + } + + const ret = await this.publishTaskService.createPub({ + inQueue: false, + queueId: '', + uid: accountInfo.uid, + userId: accountInfo.userId, + accountType: accountInfo.type, + ...body, + publishTime: publishTimeDate, + imgUrlList: imgUrlList?.split(','), + topics: topics?.split(','), + }) + + return ret + } + catch (error) { + this.logger.error('----------- plugin createPub error ------------', error) + throw error + } + } + + /** + * 获取流水的发布任务列表 + * @returns + */ + @HttpCode(200) + @UseGuards(SkKeyAuthGuard) + @Get('publish/task/list/:flowId') + async publishTaskList( + @Param('flowId') flowId: string, + ) { + const res = await this.publishTaskService.getPublishTaskListByFlowId(flowId) + return res + } + + /** + * 获取发布记录列表 + * @param body + * @returns + */ + @HttpCode(200) + @UseGuards(SkKeyAuthGuard) + @Get('publish/task/info/:taskId') + async publishRecordList( + @GetSkKey() skKey: SkKey, + @Param('taskId') taskId: string, + ) { + const res = await this.publishTaskService.getPublishTaskInfo(taskId) + + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.controller.ts new file mode 100644 index 000000000..e6f3618a0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.controller.ts @@ -0,0 +1,134 @@ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common' +import { BilibiliService } from './bilibili.service' +import { + AccountIdDto, + ArchiveListDto, + CreateAccountAndSetAccessTokenDto, + GetArcStatDto, + GetAuthInfoDto, + GetAuthUrlDto, + GetHeaderDto, +} from './dto/bilibili.dto' + +@Controller() +export class BilibiliController { + constructor(private readonly bilibiliService: BilibiliService) { } + + @Get('config') + async getBilibiliConfig( + ) { + return this.bilibiliService.getBilibiliConfig() + } + + // 创建授权任务 + // @NatsMessagePattern('plat.bilibili.auth') + @Post('plat/bilibili/auth') + createAuthTask(@Body() data: GetAuthUrlDto) { + const res = this.bilibiliService.createAuthTask({ + userId: data.userId, + type: data.type, + spaceId: data.spaceId, + }) + return res + } + + // 查询认证信息 + // @NatsMessagePattern('plat.bilibili.getAuthInfo') + @Post('plat/bilibili/getAuthInfo') + getAuthInfo(@Body() data: GetAuthInfoDto) { + const res = this.bilibiliService.getAuthInfo(data.taskId) + return res + } + + // 查询账号的认证信息 + // @NatsMessagePattern('plat.bilibili.getAccountAuthInfo') + @Post('plat/bilibili/getAccountAuthInfo') + getAccountAuthInfo(@Body() data: AccountIdDto) { + const res = this.bilibiliService.getAccountAuthInfo(data.accountId) + return res + } + + // 获取鉴权头 + // @NatsMessagePattern('plat.bilibili.getHeader') + @Post('plat/bilibili/getHeader') + async getHeader(@Body() data: GetHeaderDto) { + const res = this.bilibiliService.generateHeader(data.accountId, { + body: data.body, + isForm: data.isForm, + }) + return res + } + + // 创建账号并设置授权Token + // @NatsMessagePattern('plat.bilibili.createAccountAndSetAccessToken') + @Post('plat/bilibili/createAccountAndSetAccessToken') + async createAccountAndSetAccessToken( + @Body() data: CreateAccountAndSetAccessTokenDto, + ) { + const res = await this.bilibiliService.createAccountAndSetAccessToken( + data.taskId, + { + code: data.code, + state: data.state, + }, + ) + return res + } + + // 查询账号已授权权限列表 + // @NatsMessagePattern('bilibili.account.scopes') + @Post('bilibili/account/scopes') + async getAccountScopes(@Body() data: AccountIdDto) { + const res = await this.bilibiliService.getAccountScopes(data.accountId) + return res + } + + // 获取分区列表 + // @NatsMessagePattern('plat.bilibili.archiveTypeList') + @Post('plat/bilibili/archiveTypeList') + async archiveTypeList(@Body() data: AccountIdDto) { + return await this.bilibiliService.archiveTypeList(data.accountId) + } + + // @NatsMessagePattern('plat.bilibili.archiveList') + @Post('plat/bilibili/archiveList') + async getArchiveList(@Body() data: ArchiveListDto) { + return await this.bilibiliService.getArchiveList(data.accountId, { + ps: data.page.pageSize, + pn: data.page.pageNo!, + status: data.filter.status, + }) + } + + // @NatsMessagePattern('plat.bilibili.userStat') + @Post('plat/bilibili/userStat') + async getUserStat(@Body() data: AccountIdDto) { + return await this.bilibiliService.getUserStat(data.accountId) + } + + // @NatsMessagePattern('plat.bilibili.arcStat') + @Post('plat/bilibili/arcStat') + async getArcStat(@Body() data: GetArcStatDto) { + return await this.bilibiliService.getArcStat( + data.accountId, + data.resourceId, + ) + } + + // @NatsMessagePattern('plat.bilibili.arcIncStat') + @Post('plat/bilibili/arcIncStat') + async getArcIncStat(@Body() data: AccountIdDto) { + return await this.bilibiliService.getArcIncStat(data.accountId) + } + + // @NatsMessagePattern('plat.bilibili.accessTokenStatus') + @Post('plat/bilibili/accessTokenStatus') + async getAccessTokenStatus(@Body() data: AccountIdDto) { + return await this.bilibiliService.getAccessTokenStatus(data.accountId) + } + + @Delete(':accountId/archives/:archiveId') + async deleteArchive(@Param() accountId: string, @Param() archiveId: string) { + return await this.bilibiliService.deleteArchive(accountId, archiveId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.module.ts new file mode 100644 index 000000000..c88b8bb6a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { BilibiliApiModule } from '../../../libs/bilibili/bilibiliApi.module' +import { OAuth2Crendential, OAuth2CrendentialSchema } from '../../../libs/database/schema/oauth2Crendential.schema' +import { BilibiliController } from './bilibili.controller' +import { BilibiliService } from './bilibili.service' + +@Module({ + imports: [ + BilibiliApiModule, + MongooseModule.forFeature([ + { name: OAuth2Crendential.name, schema: OAuth2CrendentialSchema }, + ]), + ], + controllers: [BilibiliController], + providers: [BilibiliService], + exports: [BilibiliService], +}) +export class BilibiliModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.service.ts new file mode 100644 index 000000000..9ff836af6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/bilibili.service.ts @@ -0,0 +1,602 @@ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { + AccountStatus, + AccountType, + NewAccount, +} from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import { Model } from 'mongoose' +import { v4 as uuidv4 } from 'uuid' +import { getCurrentTimestamp } from '../../../common/utils/time.util' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { FileService } from '../../../core/file/file.service' +import { BilibiliApiService } from '../../../libs/bilibili/bilibiliApi.service' +import { + AccessToken, + AddArchiveData, + ArchiveStatus, + VideoUTypes, +} from '../../../libs/bilibili/common' +import { OAuth2Crendential } from '../../../libs/database/schema/oauth2Crendential.schema' +import { AuthTaskInfo } from '../common' +import { BilibiliAuthInfo } from './common' + +@Injectable() +export class BilibiliService { + private readonly platform = AccountType.BILIBILI + private readonly logger = new Logger(BilibiliService.name) + constructor( + private readonly redisService: RedisService, + private readonly bilibiliApiService: BilibiliApiService, + private readonly accountService: AccountService, + private readonly fileService: FileService, + @InjectModel(OAuth2Crendential.name) + private OAuth2CrendentialModel: Model, + ) {} + + async getBilibiliConfig() { + return config.bilibili + } + + private getAuthDataCacheKey(taskId: string) { + return `channel:bilibili:authTask:${taskId}` + } + + /** + * 创建用户授权任务 + * @param data + * @param options + */ + async createAuthTask( + data: { + userId: string + type: 'h5' | 'pc' + spaceId: string + }, + options?: { + transpond?: string + accountAddPath?: string + }, + ) { + const taskId = uuidv4() + const urlInfo = await this.getAuthUrl(taskId, data.type) + const rRes = await this.redisService.setJson( + this.getAuthDataCacheKey(taskId), + { + taskId, + spaceId: data.spaceId, + transpond: options?.transpond, + accountAddPath: options?.accountAddPath, + data: { + state: urlInfo.state, + userId: data.userId, + }, + status: 0, + }, + 60 * 5, + ) + + return rRes + ? { + url: urlInfo.url, + taskId, + } + : null + } + + /** + * 获取用户的授权链接 + * @param taskId + * @param type + * @returns + */ + async getAuthUrl(taskId: string, type: 'h5' | 'pc') { + const gourl = `${config.bilibili.authBackHost}/${taskId}` + const urlInfo = this.bilibiliApiService.getAuthPage(gourl, type) + return urlInfo + } + + /** + * 获取用户的授权信息 + * @param taskId + * @returns + */ + async getAuthInfo(taskId: string) { + const data = await this.redisService.getJson<{ + state: string + status: number + accountId?: string + }>(this.getAuthDataCacheKey(taskId)) + return data + } + + async getAccessTokenStatus(accountId: string): Promise { + const tokenInfo = await this.getOAuth2Credential(accountId) + if (!tokenInfo) + return 0 + const now = getCurrentTimestamp() + return tokenInfo.expires_in > now ? 1 : 0 + } + + /** + * 获取用户的授权信息 + * @param accountId + * @returns + */ + async getAccountAuthInfo(accountId: string) { + const data = await this.redisService.getJson( + `bilibili:accessToken:${accountId}`, + ) + return data + } + + /** + * 获取请求头 + * @param accountId + * @param data + * @returns + */ + async generateHeader( + accountId: string, + data: { + body?: { [key: string]: any } + isForm?: boolean + }, + ) { + const accessToken = await this.getAccountAccessToken(accountId) + + const headerData = { + accessToken, + ...data, + } + return this.bilibiliApiService.generateHeader(headerData) + } + + /** + * 获取用户信息 + * @param userId + * @returns + */ + async getAccountInfo(accessToken: string) { + const bilibiliUserInfo + = await this.bilibiliApiService.getAccountInfo(accessToken) + if (!bilibiliUserInfo) + return null + + try { + const newPath = await this.fileService.upFileByUrl( + bilibiliUserInfo.face, + { + path: 'account/avatar', + permanent: true, + }, + ) + + bilibiliUserInfo.face = newPath + } + catch (error) { + this.logger.log('---- bilibil getAccountInfo chage face error ', error) + + bilibiliUserInfo.face = 'error' + } + + return bilibiliUserInfo + } + + private async saveOAuthCredential( + accountId: string, + accessTokenInfo: AccessToken, + ) { + const cached = await this.redisService.setJson( + `${this.platform}:accessToken:${accountId}`, + accessTokenInfo, + ) + const persistResult = await this.OAuth2CrendentialModel.updateOne( + { + accountId, + platform: this.platform, + }, + { + accessToken: accessTokenInfo.access_token, + refreshToken: accessTokenInfo.refresh_token, + accessTokenExpiresAt: accessTokenInfo.expires_in, + }, + { + upsert: true, + }, + ) + const saved + = cached + && (persistResult.modifiedCount > 0 || persistResult.upsertedCount > 0) + return saved + } + + /** + * 创建账号+设置授权Token + * @param taskId + * @param data + * @returns + */ + async createAccountAndSetAccessToken( + taskId: string, + data: { code: string, state: string }, + ): Promise<{ + status: number + message?: string + accountId?: string + }> { + const cacheKey = this.getAuthDataCacheKey(taskId) + const { code, state } = data + + const taskInfo + = await this.redisService.getJson>(cacheKey) + if (!taskInfo || taskInfo.status !== 0) { + return { + status: 0, + message: '授权超时', + } + } + + if (taskInfo.data?.state !== state) { + return { + status: 0, + message: '授权认证失败', + } + } + + // 延长授权时间 + void this.redisService.expire(cacheKey, 60 * 3) + + // 获取token,创建账号 + const accessTokenInfo = await this.bilibiliApiService.getAccessToken(code) + if (!accessTokenInfo) { + return { + status: 0, + message: '平台认证失效', + } + } + + // 获取B站用户信息 + const bilibiliUserInfo = await this.getAccountInfo( + accessTokenInfo.access_token, + ) + if (!bilibiliUserInfo) { + return { + status: 0, + message: '获取用户信息失败,请稍后再试', + } + } + + // 创建本平台的平台账号 + const newData = new NewAccount({ + userId: taskInfo.data.userId, + type: AccountType.BILIBILI, + uid: bilibiliUserInfo.openid, + account: bilibiliUserInfo.openid, + avatar: bilibiliUserInfo.face, + nickname: bilibiliUserInfo.name, + groupId: taskInfo.spaceId, + status: AccountStatus.NORMAL, + }) + const accountInfo = await this.accountService.createAccount( + taskInfo.data.userId, + { + type: AccountType.BILIBILI, + uid: bilibiliUserInfo.openid, + }, + newData, + ) + if (!accountInfo) { + return { + status: 0, + message: '创建频道账号失败', + } + } + + let res = await this.saveOAuthCredential(accountInfo.id, accessTokenInfo) + + if (!res) { + return { + status: 0, + message: '设置授权Token失败,请稍后再试', + } + } + + // 更新任务信息 + taskInfo.status = 1 + taskInfo.data.accountId = accountInfo.id + res = await this.redisService.setJson( + cacheKey, + taskInfo, + 60 * 3, + ) + + return res + ? { + status: 1, + accountId: accountInfo.id, + } + : { + status: 0, + message: '设置授权Token失败,请稍后再试', + } + } + + private async getOAuth2Credential( + accountId: string, + ): Promise { + let credential = await this.redisService.getJson( + `${this.platform.toLowerCase()}:accessToken:${accountId}`, + ) + if (!credential) { + const oauth2Credential = await this.OAuth2CrendentialModel.findOne( + { + accountId, + platform: this.platform, + }, + ) + if (!oauth2Credential) { + return null + } + credential = { + access_token: oauth2Credential.accessToken, + refresh_token: oauth2Credential.refreshToken, + expires_in: oauth2Credential.accessTokenExpiresAt, + scopes: [], + } + } + return credential + } + + /** + * 获取用户的授权Token + * @param accountId + * @returns + */ + async getAccountAccessToken(accountId: string): Promise { + const credential = await this.getOAuth2Credential(accountId) + if (!credential) { + return Promise.resolve('') + } + if (!credential.access_token) { + return Promise.resolve('') + } + + // 剩余时间 + const overTime = credential.expires_in - getCurrentTimestamp() + + if (overTime > 60 * 10) + return credential.access_token + + return await this.refreshAccessToken(accountId, credential.refresh_token) + } + + /** + * 刷新AccessToken + * @param userId + * @param refreshToken + * @returns + */ + private async refreshAccessToken( + accountId: string, + refreshToken: string, + ): Promise { + const accessTokenInfo + = await this.bilibiliApiService.refreshAccessToken(refreshToken) + if (!accessTokenInfo) + return '' + + const res = await this.saveOAuthCredential(accountId, accessTokenInfo) + if (!res) + return '' + + return accessTokenInfo.access_token + } + + /** + * 查询用户已授权权限列表 + * @returns + */ + async getAccountScopes(accountId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + const res = await this.bilibiliApiService.getAccountScopes(accessToken) + return res + } + + /** + * 视频初始化 + * @param accountId + * @param fileName + * @param utype // 1-单个小文件(不超过100M)。默认值为0 + * @returns + */ + async videoInit( + accountId: string, + fileName: string, + utype: VideoUTypes = 0, + ): Promise { + const accessToken = await this.getAccountAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + return this.bilibiliApiService.videoInit(accessToken, fileName, utype) + } + + /** + * 文件分片上传 + * @param accountId 账户ID + * @param fileBuffer + * @param uploadToken + * @param partNumber + */ + async uploadVideoPart( + accountId: string, + fileBuffer: Buffer, + uploadToken: string, + partNumber: number, + ) { + const accessToken = await this.getAccountAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + return await this.bilibiliApiService.uploadVideoPart( + accessToken, + fileBuffer, + uploadToken, + partNumber, + ) + } + + /** + * 文件分片合片 + * @param accountId 账户ID + * @param file base64 字符串 + */ + async videoComplete(accountId: string, uploadToken: string) { + const accessToken = await this.getAccountAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + const res = await this.bilibiliApiService.videoComplete( + accessToken, + uploadToken, + ) + + return res + } + + /** + * 封面上传 + * @param accountId 账户ID + * @param fileBase64 base64 字符串 + */ + async coverUpload(accountId: string, fileBase64: string) { + const accessToken = await this.getAccountAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + const res = await this.bilibiliApiService.coverUpload( + accessToken, + fileBase64, + ) + + return res + } + + /** + * 小视频上传 100M以下 + * @param accountId 账户ID + * @param file base64 字符串 + * @param uploadToken 上传标识 + */ + async uploadLitVideo( + accountId: string, + fileBase64: string, + uploadToken: string, + ) { + const accessToken = await this.getAccountAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + const file = Buffer.from(fileBase64, 'base64') + const res = await this.bilibiliApiService.uploadLitVideo( + accessToken, + file, + uploadToken, + ) + + return res + } + + /** + * 视频稿件提交 + * @param accessToken + * @param uploadToken + * @param data + * @returns + */ + async archiveAddByUtoken( + accountId: string, + uploadToken: string, + data: AddArchiveData, + ): Promise { + const accessToken = await this.getAccountAccessToken(accountId) + + return this.bilibiliApiService.archiveAddByUtoken( + accessToken, + uploadToken, + data, + ) + } + + /** + * 分区查询 + * @param accountId + * @returns + */ + async archiveTypeList(accountId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.archiveTypeList(accessToken) + } + + /** + * 获取稿件列表 + * @param accountId + * @returns + */ + async getArchiveList( + accountId: string, + params: { + ps: number + pn: number + status?: ArchiveStatus + }, + ) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.getArchiveList(accessToken, params) + } + + /** + * 获取用户数据 + * @param accountId + * @returns + */ + async getUserStat(accountId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.getUserStat(accessToken) + } + + /** + * 获取稿件数据 + * @param accountId + * @param resourceId + * @returns + */ + async getArcStat(accountId: string, resourceId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.getArcStat(accessToken, resourceId) + } + + /** + * 获取稿件增量数据数据 + * @param accountId + * @returns + */ + async getArcIncStat(accountId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.getArcIncStat(accessToken) + } + + /** + * 删除稿件 + * @param accountId + * @param resourceId + * @returns + */ + async deleteArchive(accountId: string, resourceId: string) { + const accessToken = await this.getAccountAccessToken(accountId) + return await this.bilibiliApiService.deleteArchive(accessToken, resourceId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/common.ts new file mode 100644 index 000000000..94b87f020 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/common.ts @@ -0,0 +1,5 @@ +export interface BilibiliAuthInfo { + state: string + userId: string + accountId?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/dto/bilibili.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/dto/bilibili.dto.ts new file mode 100644 index 000000000..ada27acba --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/bilibili/dto/bilibili.dto.ts @@ -0,0 +1,204 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose, Type } from 'class-transformer' +import { + IsBoolean, + IsEnum, + IsNumber, + IsObject, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator' +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: nevin + * @Description: b站 + */ +import { TableDto } from '../../../../common/global/dto/table.dto' +import { AddArchiveData, ArchiveStatus } from '../../../../libs/bilibili/common' + +export class AccountIdDto { + @IsString({ message: '账号ID' }) + @Expose() + readonly accountId: string +} +export class UserIdDto { + @IsString({ message: '用户ID' }) + @Expose() + readonly userId: string +} + +export class GetAuthUrlDto extends UserIdDto { + @IsString({ message: '空间ID' }) + @Expose() + readonly spaceId: string + + @IsString({ message: '类型 pc h5' }) + @Expose() + readonly type: 'h5' | 'pc' + + @IsString({ message: '前缀' }) + @IsOptional() + @Expose() + readonly prefix?: string +} + +export class GetAuthInfoDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string +} + +export class GetHeaderDto extends AccountIdDto { + @IsObject({ message: '数据' }) + @Expose() + readonly body: { [key: string]: any } + + @IsBoolean({ message: '是否是表单提交' }) + @Expose() + readonly isForm: boolean +} + +export class VideoInitDto extends AccountIdDto { + @IsNumber( + { allowNaN: false }, + { + message: + '上传类型:0,1。0-多分片,1-单个小文件(不超过100M)。默认值为0', + }, + ) + @Type(() => Number) + @Expose() + readonly utype: number // 0 1 + + @IsString({ message: '文件名称' }) + @Expose() + readonly name: string +} + +export class UploadLitVideoDto extends AccountIdDto { + @IsString({ message: '文件流 base64编码' }) + @Expose() + readonly file: string + + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string +} + +export class UploadVideoPartDto extends UploadLitVideoDto { + @IsNumber( + { allowNaN: false }, + { + message: '分片索引', + }, + ) + @Type(() => Number) + @Expose() + readonly partNumber: number +} + +export class VideoCompleteDto extends AccountIdDto { + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string +} + +export class CoverUploadDto extends AccountIdDto { + @IsString({ message: '文件流 base64编码' }) + @Expose() + readonly file: string +} + +export class AddArchiveDataDto implements AddArchiveData { + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '封面' }) + @IsOptional() + @Expose() + readonly cover?: string + + @IsNumber({ allowNaN: false }, { message: '分区ID' }) + @Expose() + readonly tid: number + + @IsNumber({ allowNaN: false }, { message: '是否允许转载' }) + @IsOptional() + @Expose() + readonly no_reprint?: 0 | 1 + + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string + + @IsString({ message: '标签' }) + @Expose() + readonly tag: string + + @IsNumber({ allowNaN: false }, { message: '1-原创,2-转载' }) + @Expose() + readonly copyright: 1 | 2 + + @IsString({ message: '转载来源' }) + @IsOptional() + @Expose() + readonly source?: string +} +export class AddArchiveDto extends AccountIdDto { + @ValidateNested() + @Type(() => AddArchiveDataDto) + @Expose() + readonly data: AddArchiveDataDto + + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string +} + +export class CreateAccountAndSetAccessTokenDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string + + @IsString({ message: '授权码' }) + @Expose() + readonly code: string + + @IsString({ message: '状态' }) + @Expose() + readonly state: string +} + +export class ArchiveListFilterDto { + @ApiProperty({ + description: '任务状态', + enum: ArchiveStatus, + required: false, + }) + @IsEnum(ArchiveStatus, { message: '任务状态' }) + @IsOptional() + status?: ArchiveStatus +} + +export class ArchiveListDto extends AccountIdDto { + @ValidateNested() + @Type(() => ArchiveListFilterDto) + @Expose() + readonly filter: ArchiveListFilterDto + + @ValidateNested() + @Type(() => TableDto) + @Expose() + readonly page: TableDto +} + +export class GetArcStatDto extends AccountIdDto { + @IsString({ message: '稿件ID' }) + @Expose() + readonly resourceId: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/common.ts new file mode 100644 index 000000000..f6110943d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/common.ts @@ -0,0 +1,56 @@ +export interface AuthTaskInfo { + taskId: string + spaceId?: string + transpond?: string // nats转发 + accountAddPath?: string // 账户添加路径 + data?: T + status: -1 | 0 | 1 // -1: 未开始, 0: 进行中, 1: 完成 + error?: string // 错误信息 +} + +export interface ChannelAccountDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number + // 稿件数量 + arcNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelAccountDataBulk extends ChannelAccountDataCube { + // 每天 + list: ChannelAccountDataCube[] +} + +export interface ChannelArcDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelArcDataBulk extends ChannelAccountDataCube { + recordId: string + dataId: string + + // 每天 + list: ChannelArcDataCube[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/constants.ts new file mode 100644 index 000000000..ece96c343 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/constants.ts @@ -0,0 +1,6 @@ +export const KWAI_TIME_CONSTANTS = { + AUTH_TASK_EXPIRE: 5 * 60, // for oauth task + AUTH_TASK_EXTEND: 3 * 60, // extend oauth task + TOKEN_REFRESH_MARGIN: 10 * 60, // margin for token refresh + TOKEN_REFRESH_THRESHOLD: 15 * 60, // threshold for token refresh +} as const diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/dto/kwai.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/dto/kwai.dto.ts new file mode 100644 index 000000000..1e0b581f1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/dto/kwai.dto.ts @@ -0,0 +1,64 @@ +import { Expose, Type } from 'class-transformer' +import { IsNumber, IsOptional, IsString } from 'class-validator' +import { UserIdDto } from '../../bilibili/dto/bilibili.dto' + +export class GetAuthUrlDto extends UserIdDto { + @IsString({ message: '类型 pc h5' }) + @Expose() + readonly type: 'h5' | 'pc' + + @IsString({ message: '空间ID' }) + @Expose() + readonly spaceId: string +} + +export class AddKwaiAccountDto extends UserIdDto { + @IsString({ message: '授权成功后的获取的code' }) + @Expose() + readonly code: string +} + +export class GetAuthInfoDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string +} + +export class CreateAccountAndSetAccessTokenDto { + @IsString() + @Expose() + readonly taskId: string + + @IsString() + @Expose() + readonly code: string + + @IsString() + @Expose() + readonly state: string +} + +export class AccountIdDto { + @IsString({ message: '账号ID' }) + @Expose() + readonly accountId: string +} + +export class GetPohotListDto extends AccountIdDto { + @IsString({ message: '游标,用于分页,值为作品id。分页查询时,传上一页create_time最小的photo_id。第一页不传此参数。' }) + @IsOptional() + @Expose() + readonly cursor?: string + + @IsNumber( + { allowNaN: false }, + { + message: + '数量,默认为20,最大不超过200', + }, + ) + @Type(() => Number) + @IsOptional() + @Expose() + readonly count?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.controller.ts new file mode 100644 index 000000000..236c9b164 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.controller.ts @@ -0,0 +1,58 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { AccountIdDto, CreateAccountAndSetAccessTokenDto, GetAuthInfoDto, GetAuthUrlDto, GetPohotListDto } from './dto/kwai.dto' +import { KwaiService } from './kwai.service' + +@Controller() +export class KwaiController { + constructor(private readonly kwaiService: KwaiService) {} + + // 获取页面的认证URL + // @NatsMessagePattern('plat.kwai.auth') + @Post('plat/kwai/auth') + getAuthUrl(@Body() data: GetAuthUrlDto) { + return this.kwaiService.createAuthTask(data) + } + + // 查询认证信息 + // @NatsMessagePattern('plat.kwai.getAuthInfo') + @Post('plat/kwai/getAuthInfo') + getAuthInfo(@Body() data: GetAuthInfoDto) { + return this.kwaiService.getAuthInfo(data.taskId) + } + + // 创建账号并设置授权Token + // @NatsMessagePattern('plat.kwai.createAccountAndSetAccessToken') + @Post('plat/kwai/createAccountAndSetAccessToken') + async createAccountAndSetAccessToken( + @Body() data: CreateAccountAndSetAccessTokenDto, + ) { + const res = await this.kwaiService.createAccountAndSetAccessToken( + data.taskId, + { + code: data.code, + state: data.state, + }, + ) + return res + } + + // 获取用户公开信息 + // @NatsMessagePattern('plat.kwai.getAuthorInfo') + @Post('plat/kwai/getAuthorInfo') + getAuthorInfo(@Body() data: AccountIdDto) { + return this.kwaiService.getAuthorInfo(data.accountId) + } + + // 获取视频列表 + // @NatsMessagePattern('plat.kwai.getPhotoList') + @Post('plat/kwai/getPhotoList') + fetchVideoList(@Body() data: GetPohotListDto) { + return this.kwaiService.fetchVideoList(data.accountId, data?.cursor, data?.count) + } + + // @NatsMessagePattern('plat.kwai.accessTokenStatus') + @Post('plat/kwai/accessTokenStatus') + async getAccessTokenStatus(@Body() data: AccountIdDto) { + return await this.kwaiService.getAccessTokenStatus(data.accountId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.module.ts new file mode 100644 index 000000000..fa7779394 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { MongooseModule } from '@nestjs/mongoose' +import { OAuth2Crendential, OAuth2CrendentialSchema } from '../../../libs/database/schema/oauth2Crendential.schema' +import { KwaiApiModule } from '../../../libs/kwai/kwaiApi.module' +import { KwaiController } from './kwai.controller' +import { KwaiService } from './kwai.service' + +@Module({ + imports: [ConfigModule, KwaiApiModule, MongooseModule.forFeature([ + { name: OAuth2Crendential.name, schema: OAuth2CrendentialSchema }, + ])], + controllers: [KwaiController], + providers: [KwaiService], + exports: [KwaiService], +}) +export class KwaiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.service.ts new file mode 100644 index 000000000..b8fd46297 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/kwai/kwai.service.ts @@ -0,0 +1,310 @@ +/* eslint-disable antfu/consistent-list-newline */ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountStatus, AccountType, NewAccount } from '@yikart/aitoearn-server-client' +import { RedisService } from '@yikart/redis' +import { Model } from 'mongoose' +import { v4 as uuidv4 } from 'uuid' +import { getCurrentTimestamp } from '../../../common' +import { AccountService } from '../../../core/account/account.service' +import { BilibiliAuthInfo } from '../../../core/plat/bilibili/common' +import { AuthTaskInfo } from '../../../core/plat/common' +import { OAuth2Crendential } from '../../../libs/database/schema/oauth2Crendential.schema' +import { KwaiOAuthCredentialsResponse, KwaiVideoPubParams } from '../../../libs/kwai/kwaiApi.interfaces' +import { KwaiApiService } from '../../../libs/kwai/kwaiApi.service' +import { KWAI_TIME_CONSTANTS } from './constants' + +@Injectable() +export class KwaiService { + private readonly platform = AccountType.KWAI + private readonly logger = new Logger(KwaiService.name) + constructor( + private readonly kwaiApiService: KwaiApiService, + private readonly redisService: RedisService, + private readonly accountService: AccountService, + @InjectModel(OAuth2Crendential.name) + private OAuth2CrendentialModel: Model, + ) { } + + private async getOAuth2Credential(accountId: string): Promise { + let credential = await this.redisService.getJson( + `${this.platform.toLowerCase()}:accessToken:${accountId}`, + ) + if (!credential) { + const oauth2Credential = await this.OAuth2CrendentialModel.findOne( + { + accountId, + platform: this.platform, + }) + if (!oauth2Credential) { + return null + } + credential = { + result: 0, + access_token: oauth2Credential.accessToken, + refresh_token: oauth2Credential.refreshToken, + expires_in: oauth2Credential.accessTokenExpiresAt, + refresh_token_expires_in: oauth2Credential.refreshTokenExpiresAt || 0, + scopes: [], + open_id: '', + } + } + return credential + } + + // 设置 AccessToken + async setAccountTokenInfo( + key: string, + accountTokenInfo: KwaiOAuthCredentialsResponse, + ) { + const expiredAt = accountTokenInfo.refresh_token_expires_in + accountTokenInfo.refresh_token_expires_in + = getCurrentTimestamp() + accountTokenInfo.refresh_token_expires_in - KWAI_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + accountTokenInfo.expires_in + = getCurrentTimestamp() + accountTokenInfo.expires_in - KWAI_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + + return await this.redisService.setJson(key, accountTokenInfo, expiredAt) + } + + /** + * 获取AccessToken并且刷新Token + * @param accountId + */ + async getAccessTokenAndRefresh(accountId: string) { + const accessTokenInfo = await this.getOAuth2Credential(accountId) + if (!accessTokenInfo) + return null + + // 判断 refresh_token 是否过期 + const isRefreshTokenExpired + = getCurrentTimestamp() >= accessTokenInfo.refresh_token_expires_in + if (isRefreshTokenExpired) { + this.logger.warn(`Kwai account ${accountId} refresh_token is expired, expired at: ${accessTokenInfo.refresh_token_expires_in}`) + return null + } + + // 判断 access_token 是否过期 + const isAccessTokenExpired = getCurrentTimestamp() >= accessTokenInfo.expires_in + if (!isAccessTokenExpired) + return accessTokenInfo.access_token + + // 刷新 accountToken + const newAccountToken = await this.kwaiApiService.refreshToken( + accessTokenInfo.refresh_token, + ) + if (!newAccountToken) { + this.logger.warn(`Kwai account ${accountId} access_token refresh failed`) + return null + } + + const success = await this.saveOAuthCredential(accountId, newAccountToken) + if (!success) { + this.logger.error(`Kwai account ${accountId} access_token save to redis failed`) + return null + } + return newAccountToken.access_token + } + + private getAuthDataCacheKey(taskId: string) { + return `channel:kwai:authTask:${taskId}` + } + + /** + * 创建用户授权任务 + * @returns + * @param data + * @param options + */ + async createAuthTask( + data: { + userId: string + type: 'h5' | 'pc' + spaceId: string + }, + options?: { + transpond?: string + accountAddPath?: string + }, + ) { + const taskId = uuidv4() + const urlInfo = this.kwaiApiService.getAuthPage(taskId, data.type) + const rRes = await this.redisService.setJson( + this.getAuthDataCacheKey(taskId), + { + taskId, + spaceId: data.spaceId, + transpond: options?.transpond, + accountAddPath: options?.accountAddPath, + data: { + state: '', + userId: data.userId, + }, + status: 0, + }, + 60 * 5, + ) + + return rRes + ? { + url: urlInfo, + taskId, + } + : null + } + + async createAccountAndSetAccessToken(taskId: string, data: { code: string, state: string }) { + const cacheKey = this.getAuthDataCacheKey(taskId) + const { code } = data + const taskInfo = await this.redisService.getJson>( + cacheKey, + ) + if (!taskInfo || taskInfo.status !== 0 || !taskInfo.data) + return { status: 0, message: '任务不存在或已完成' } + + // 延长授权时间 + void this.redisService.expire(cacheKey, 60 * 3) + + try { + const account = await this.addKwaiAccount(code, taskInfo.data.userId, taskInfo.spaceId) + if (account) { + // 更新任务信息 + taskInfo.status = 1 + taskInfo.data['accountId'] = account.id + await this.redisService.setJson( + cacheKey, + taskInfo, + 60 * 3, + ) + return { status: 1, message: '添加账号成功', accountId: account.id } + } + else { + return { status: 0, message: '添加账号失败' } + } + } + catch (error) { + this.logger.error('createAccountAndSetAccessToken error:', error) + return { status: 0, message: `添加账号失败: ${error.message}` } + } + } + + async getAuthInfo(taskId: string) { + return await this.redisService.getJson<{ + state: string + status: number + accountId?: string + }>(this.getAuthDataCacheKey(taskId)) + } + + private async saveOAuthCredential(accountId: string, accessTokenInfo: KwaiOAuthCredentialsResponse) { + accessTokenInfo.expires_in = accessTokenInfo.expires_in + getCurrentTimestamp() - KWAI_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + accessTokenInfo.refresh_token_expires_in = accessTokenInfo.refresh_token_expires_in + getCurrentTimestamp() - KWAI_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + const cached = await this.redisService.setJson( + `${this.platform.toLowerCase()}:accessToken:${accountId}`, + accessTokenInfo, + ) + const persistResult = await this.OAuth2CrendentialModel.updateOne({ + accountId, + platform: this.platform, + }, { + accessToken: accessTokenInfo.access_token, + refreshToken: accessTokenInfo.refresh_token, + accessTokenExpiresAt: accessTokenInfo.expires_in, + refreshTokenExpiresAt: accessTokenInfo.refresh_token_expires_in, + }, { + upsert: true, + }) + const saved = cached && (persistResult.modifiedCount > 0 || persistResult.upsertedCount > 0) + return saved + } + + // 根据code添加快手账户 + async addKwaiAccount(code: string, userId: string, spaceId = '') { + this.logger.log(code, userId) + // 获取快手token + const accountTokenInfo + = await this.kwaiApiService.getLoginAccountToken(code) + if (!accountTokenInfo) + throw new Error('获取快手token失败') + + // 获取快手用户信息 + const kwaiUserInfo = await this.kwaiApiService.getAccountInfo(accountTokenInfo.access_token) + if (!kwaiUserInfo) + throw new Error('快手用户信息获取失败') + + // 创建本平台的平台账号 + const newData = new NewAccount({ + userId, + type: AccountType.KWAI, + uid: accountTokenInfo.open_id, + account: accountTokenInfo.open_id, + avatar: kwaiUserInfo.bigHead, + nickname: kwaiUserInfo.name, + status: AccountStatus.NORMAL, + groupId: spaceId, + }) + + const accountInfo = await this.accountService.createAccount( + userId, + { + type: AccountType.KWAI, + uid: accountTokenInfo.open_id, + }, + newData, + ) + if (!accountInfo) + throw new Error('添加账号失败') + + const res = await this.saveOAuthCredential(accountInfo.id, accountTokenInfo) + + if (!res) + throw new Error('设置redis失败') + + return accountInfo + } + + // 视频发布 + async publishVideo(accountId: string, pubParams: KwaiVideoPubParams) { + const accountToken = await this.getAccessTokenAndRefresh(accountId) + if (accountToken === null) { + this.logger.warn(`Kwai account ${accountId} access_token is expired or invalid`) + throw new Error('kwai account access_token is expired or invalid') + } + return await this.kwaiApiService.publishVideo(accountToken, pubParams) + } + + // 获取用户公开信息 + async getAuthorInfo(accountId: string) { + const accountToken = await this.getAccessTokenAndRefresh(accountId) + if (accountToken === null) { + this.logger.warn(`Kwai account ${accountId} access_token is expired or invalid`) + throw new Error('kwai account access_token is expired or invalid') + } + return await this.kwaiApiService.getAccountInfo(accountToken) + } + + // 获取视频列表 + async fetchVideoList(accountId: string, cursor?: string, count?: number) { + const accountToken = await this.getAccessTokenAndRefresh(accountId) + if (accountToken === null) { + this.logger.warn(`Kwai account ${accountId} access_token is expired or invalid`) + throw new Error('kwai account access_token is expired or invalid') + } + return await this.kwaiApiService.fetchVideoList(accountToken, cursor, count) + } + + async getAccessTokenStatus(accountId: string) { + const tokenInfo = await this.getOAuth2Credential(accountId) + if (!tokenInfo) + return 0 + return tokenInfo.refresh_token_expires_in > getCurrentTimestamp() ? 1 : 0 + } + + async deleteVideo(accountId: string, videoId: string) { + const accountToken = await this.getAccessTokenAndRefresh(accountId) + if (accountToken === null) { + this.logger.warn(`Kwai account ${accountId} access_token is expired or invalid`) + throw new Error('kwai account access_token is expired or invalid') + } + return await this.kwaiApiService.deleteVideo(accountToken, videoId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/constants.ts new file mode 100644 index 000000000..6cdc74edc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/constants.ts @@ -0,0 +1,60 @@ +import { FacebookOAuth2Config } from '../../../libs/facebook/constants' +import { InstagramOAuth2Config } from '../../../libs/instagram/constants' +import { LinkedinOAuth2Config } from '../../../libs/linkedin/constants' +import { ThreadsOAuth2Config } from '../../../libs/threads/constants' + +export class MetaRedisKeys { + private static readonly PREFIX = 'meta:' + + static getAuthTaskKey(state: string): string { + return `${this.PREFIX}auth_task:${state}` + } + + static getAccessTokenKey(platform: string, accountId: string): string { + return `${platform}:access_token:${accountId}` + } + + static getUserPageAccessTokenKey(platform: string, pageId: string): string { + return `${platform}:page:access_token:${pageId}` + } + + static getUserPageListKey(platform: string, accountId: string): string { + return `${platform}:user_page_list:${accountId}` + } +} + +// thresholds for twitter oAuth +export const META_TIME_CONSTANTS = { + AUTH_TASK_EXPIRE: 5 * 60, // for oauth task + AUTH_TASK_EXTEND: 3 * 60, // extend oauth task + TOKEN_REFRESH_MARGIN: 60 * 60, // margin for token refresh + TOKEN_REFRESH_THRESHOLD: 15 * 60, // threshold for token refresh + FACEBOOK_LONG_LIVED_TOKEN_DEFAULT_EXPIRE: 60 * 60 * 24 * 60, // 60 days +} as const + +interface MetaOAuth2Config { + pkce: boolean + shortLived: boolean + apiBaseUrl: string + authURL: string + accessTokenURL: string + pageAccountURL: string + longLivedAccessTokenURL?: string + refreshTokenURL?: string + userProfileURL: string + requestAccessTokenMethod: 'POST' | 'GET' + defaultScopes: string[] + longLivedGrantType?: string + longLivedParamsMap?: Record + scopesSeparator: string +} +export interface MetaOAuth2ConfigMap { + [platform: string]: MetaOAuth2Config +} + +export const metaOAuth2ConfigMap: MetaOAuth2ConfigMap = { + facebook: FacebookOAuth2Config as MetaOAuth2Config, + threads: ThreadsOAuth2Config as MetaOAuth2Config, + instagram: InstagramOAuth2Config as MetaOAuth2Config, + linkedin: LinkedinOAuth2Config as MetaOAuth2Config, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/dto/meta.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/dto/meta.dto.ts new file mode 100644 index 000000000..e8286603d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/dto/meta.dto.ts @@ -0,0 +1,79 @@ +import { createZodDto } from '@yikart/common' +import { Expose } from 'class-transformer' +import { IsArray, IsOptional, IsString } from 'class-validator' +import { z } from 'zod' + +export class AccountIdDto { + @IsString() + @Expose() + readonly accountId: string +} + +export class UserIdDto { + @IsString() + @Expose() + readonly userId: string +} + +export class PagesSelectionDto extends UserIdDto { + @IsArray() + @Expose() + readonly pageIds: string[] +} + +export class GetAuthUrlDto extends UserIdDto { + @IsString({ message: '空间ID' }) + @Expose() + readonly spaceId: string + + @IsArray() + @IsOptional() + @Expose() + readonly scopes?: string[] + + @IsString() + @Expose() + readonly platform: string // Optional, can be 'facebook', 'instagram', or 'thread' +} + +export class GetAuthInfoDto { + @IsString() + @Expose() + readonly taskId: string +} + +export class CreateAccountAndSetAccessTokenDto { + @IsString() + @Expose() + readonly code: string + + @IsString() + @Expose() + readonly state: string +} + +export class RefreshTokenDto extends AccountIdDto { + @IsString() + @Expose() + readonly refreshToken: string +} + +export const ListCommentsSchema = z.object({ + platform: z.enum(['facebook', 'instagram', 'threads']), + accountId: z.string(), + targetId: z.string(), + targetType: z.enum(['post', 'comment']), + before: z.string().nullish(), + after: z.string().nullish(), +}) + +export const CreateCommentSchema = z.object({ + platform: z.enum(['facebook', 'instagram', 'threads']), + accountId: z.string(), + targetId: z.string(), + targetType: z.enum(['post', 'comment']), + message: z.string(), +}) + +export class ListCommentsDto extends createZodDto(ListCommentsSchema) {} +export class CreateCommentDto extends createZodDto(CreateCommentSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/facebook.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/facebook.service.ts new file mode 100644 index 000000000..eebe177dd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/facebook.service.ts @@ -0,0 +1,479 @@ +import { Injectable, Logger } from '@nestjs/common' +import { RedisService } from '@yikart/redis' +import axios, { AxiosResponse } from 'axios' +import { getCurrentTimestamp } from '../../../common' +import { + ChunkedVideoUploadRequest, + ChunkedVideoUploadResponse, + FacebookInitialVideoUploadRequest, + FacebookInitialVideoUploadResponse, + FacebookInsightsRequest, + FacebookInsightsResponse, + FacebookObjectInfo, + FacebookPageDetailRequest, + FacebookPageDetailResponse, + FacebookPagePostRequest, + FacebookPostAttachmentsResponse, + FacebookPostCommentsRequest, + FacebookPostCommentsResponse, + FacebookPostDetailResponse, + FacebookPostEdgesRequest, + FacebookPostEdgesResponse, + FacebookPublishedPostRequest, + FacebookPublishedPostResponse, + FacebookReelRequest, + FacebookReelResponse, + FacebookReelUploadRequest, + FacebookReelUploadResponse, + FacebookSearchPagesRequest, + finalizeVideoUploadRequest, + finalizeVideoUploadResponse, + PublishFeedPostRequest, + publishFeedPostResponse, + PublishMediaPostResponse, + PublishVideoPostRequest, + publishVideoPostResponse, + UploadPhotoResponse, +} from '../../../libs/facebook/facebook.interfaces' +import { FacebookService as FacebookAPIService } from '../../../libs/facebook/facebook.service' +import { META_TIME_CONSTANTS, metaOAuth2ConfigMap, MetaRedisKeys } from './constants' +import { FacebookAccountResponse, FacebookPageCredentials, MetaFacebookPageResponse, MetaUserOAuthCredential } from './meta.interfaces' + +@Injectable() +export class FacebookService { + private readonly redisService: RedisService + private readonly facebookAPIService: FacebookAPIService + private readonly logger = new Logger(FacebookService.name) + + constructor( + redisService: RedisService, + facebookAPIService: FacebookAPIService, + ) { + this.redisService = redisService + this.facebookAPIService = facebookAPIService + } + + private async authorize( + accountId: string, + ): Promise { + const credential = await this.redisService.getJson( + MetaRedisKeys.getAccessTokenKey('facebook', accountId), + ) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const now = getCurrentTimestamp() + const tokenExpiredAt = now + credential.expires_in + const requestTime + = tokenExpiredAt - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + if (requestTime <= now) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const refreshedToken = await this.refreshOAuthCredential( + credential.access_token, + ) + if (!refreshedToken) { + this.logger.error( + `Failed to refresh access token for accountId: ${accountId}`, + ) + return null + } + credential.access_token = refreshedToken.access_token + credential.expires_in = refreshedToken.expires_in || META_TIME_CONSTANTS.FACEBOOK_LONG_LIVED_TOKEN_DEFAULT_EXPIRE + const saved = await this.saveOAuthCredential(accountId, credential, 'facebook') + if (!saved) { + this.logger.error( + `Failed to save refreshed access token for accountId: ${accountId}`, + ) + return null + } + return credential + } + return credential + } + + async getUserAccount( + accessToken: string, + ) { + const accountURL = metaOAuth2ConfigMap['facebook'].pageAccountURL || 'https://graph.facebook.com/v23.0/me/accounts' + const response: AxiosResponse = await axios.get( + accountURL, + { + params: { + access_token: accessToken, + }, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + const data = response.data.data || [] + return data + } + + private async authorizePage( + accountId: string, + ): Promise { + const pageCredential = await this.redisService.getJson( + MetaRedisKeys.getUserPageAccessTokenKey('facebook', accountId), + ) + if (!pageCredential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + throw new Error(`No access token found for accountId: ${accountId}`) + } + const now = getCurrentTimestamp() + const tokenExpiredAt = now + pageCredential.expires_in + const requestTime + = tokenExpiredAt - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + if (requestTime <= now) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const userCredential = await this.authorize(pageCredential.facebook_user_id) + if (!userCredential) { + this.logger.error( + `Failed to refresh access token for facebook accountId: ${pageCredential.facebook_user_id}`, + ) + throw new Error(`Failed to refresh access token for facebook accountId: ${pageCredential.facebook_user_id}`) + } + const fbAccountInfo = await this.getUserAccount( + userCredential.access_token, + ) + let newPageCredential: FacebookPageCredentials | null = null + if (fbAccountInfo.length > 0) { + for (const fbAccount of fbAccountInfo) { + fbAccount.expires_in = userCredential.expires_in + const credential = { ...fbAccount, facebook_user_id: userCredential.user_id, expires_in: userCredential.expires_in } + if (fbAccount.id === pageCredential.id) { + newPageCredential = credential + } + await this.redisService.setJson( + MetaRedisKeys.getUserPageAccessTokenKey( + 'facebook', + fbAccount.id, + ), + credential, + ) + } + } + if (!newPageCredential) { + this.logger.error( + `Failed to find page access token for accountId: ${accountId} after refreshing`, + ) + throw new Error(`Failed to find page access token for accountId: ${accountId} after refreshing`) + } + return newPageCredential + } + return pageCredential + } + + private async refreshOAuthCredential(refresh_token: string) { + const credential + = await this.facebookAPIService.refreshOAuthCredential(refresh_token) + if (!credential) { + this.logger.error(`Failed to refresh access token`) + return null + } + return credential + } + + private async saveOAuthCredential( + accountId: string, + tokenInfo: MetaUserOAuthCredential, + platform: string, + ): Promise { + const now = getCurrentTimestamp() + const expireTime + = now + tokenInfo.expires_in - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + tokenInfo.expires_in = expireTime + return await this.redisService.setJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + tokenInfo, + ) + } + + async initVideoUpload( + accountId: string, + req: FacebookInitialVideoUploadRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.initVideoUpload(credential.id, credential.access_token, req) + } + + async chunkedMediaUpload( + accountId: string, + req: ChunkedVideoUploadRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.chunkedVideoUploadRequest(credential.id, credential.access_token, req) + } + + async finalizeMediaUpload( + accountId: string, + req: finalizeVideoUploadRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return this.facebookAPIService.finalizeVideoUpload(credential.id, credential.access_token, req) + } + + async publishFeedPost( + accountId: string, + req: PublishFeedPostRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishFeedPost(credential.id, credential.access_token, req) + } + + async publishVideoPost( + accountId: string, + req: PublishVideoPostRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishVideoPost(credential.id, credential.access_token, req) + } + + async uploadImage( + accountId: string, + file: Blob, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.uploadPostPhotoByFile(credential.id, credential.access_token, file) + } + + async publicPhotoPost( + accountId: string, + imageUrlList: string[], + caption?: string, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishMultiplePhotoPost(credential.id, credential.access_token, imageUrlList, caption) + } + + async getObjectInfo(accountId: string, objectId: string, fields?: string): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.getObjectInfo(credential.access_token, objectId, fields) + } + + async getPageInsights( + accountId: string, + req: FacebookInsightsRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.getPageInsights(credential.id, credential.access_token, req) + } + + async getPageDetail( + accountId: string, + query: FacebookPageDetailRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.getPageDetails(credential.id, credential.access_token, query) + } + + async getPagePublishedPosts( + accountId: string, + query: FacebookPublishedPostRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.getPagePublishedPosts(credential.id, credential.access_token, query) + } + + async getAccountInsights( + accountId: string, + ) { + const pageInsights = await this.getPageInsights(accountId, { + metric: 'page_video_views', + period: 'lifetime', + }) + const pageDetail = await this.getPageDetail(accountId, { fields: 'followers_count' }) + const fensNum = pageDetail?.followers_count || 0 + const playNum = pageInsights?.data.find( + item => item.name === 'page_video_views', + )?.values[0].value || 0 + return { + fensNum, + playNum, + } + } + + async getPostInsights( + accountId: string, + postId: string, + ) { + const credential = await this.authorizePage(accountId) + const postInsightReq: FacebookInsightsRequest = { + // metric: 'post_reactions_like_total,post_video_views', + metric: 'post_impressions,post_clicks,post_reactions_like_total,post_video_views', + period: 'lifetime', + } + const objectId = `${credential.id}_${postId}` + const postInsights = await this.facebookAPIService.getFacebookObjectInsights(objectId, credential.access_token, postInsightReq) + const postDetail = await this.facebookAPIService.getPagePostDetails( + objectId, + credential.access_token, + { field: 'shares' }, + ) + const comments = await this.facebookAPIService.getPostComments( + objectId, + credential.access_token, + { summary: true, type: 'LIKE' }, + ) + const viewCount = postInsights?.data.find( + item => item.name === 'post_video_views', + )?.values[0].value || 0 + const likeCount = postInsights?.data.find( + item => item.name === 'post_reactions_like_total', + )?.values[0].value || 0 + const clickCount = postInsights?.data.find( + item => item.name === 'post_clicks', + )?.values[0].value || 0 + const impressionCount = postInsights?.data.find( + item => item.name === 'post_impressions', + )?.values[0].value || 0 + // const commentCount = postInsights?.data.find( + // item => item.name === 'post_comments', + // )?.values[0].value || 0 + const commentCount = comments?.summary?.total_count || 0 + const shareCount = postDetail?.shares?.count || 0 + return { + playNum: viewCount, + commentNum: commentCount, + likeNum: likeCount, + shareNum: shareCount, + clickNum: clickCount, + impressionNum: impressionCount, + } + } + + async initReelUpload( + accountId: string, + req: FacebookReelRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.initReelUpload(credential.id, credential.access_token, req) + } + + async uploadReel( + accountId: string, + uploadURL: string, + req: FacebookReelUploadRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.uploadReel(credential.access_token, uploadURL, req) + } + + async publishReel( + accountId: string, + req: FacebookReelRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishReelPost(credential.id, credential.access_token, req) + } + + async initVideoStoryUpload( + accountId: string, + req: FacebookReelRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.initVideoStoryUpload(credential.id, credential.access_token, req) + } + + async uploadVideoStory( + accountId: string, + uploadURL: string, + req: FacebookReelUploadRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.uploadVideoStory(credential.access_token, uploadURL, req) + } + + async publishVideoStory( + accountId: string, + req: FacebookReelRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishVideoStoryPost(credential.id, credential.access_token, req) + } + + async publishPhotoStory( + accountId: string, + photoId: string, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishPhotoStoryPost(credential.id, credential.access_token, photoId) + } + + async getPostPosts( + accountId: string, + query: FacebookPagePostRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.fetchPagePosts(credential.id, credential.access_token, query) + } + + async getPostComments( + accountId: string, + postId: string, + query: FacebookPostEdgesRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + const objectId = `${credential.id}_${postId}` + return await this.facebookAPIService.getPostComments(objectId, credential.access_token, query) + } + + async fetchObjectComments( + accountId: string, + objectId: string, + query: FacebookPostCommentsRequest, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.fetchObjectComments(objectId, credential.access_token, query) + } + + async publishPlaintextComment( + accountId: string, + objectId: string, + message: string, + ): Promise<{ id: string }> { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.publishPlaintextComment(objectId, credential.access_token, message) + } + + async searchPages( + accountId: string, + keyword: string, + ): Promise { + const credential = await this.authorizePage(accountId) + const query: FacebookSearchPagesRequest = { + q: keyword, + fields: 'id,name,location,link', + } + const resp = await this.facebookAPIService.searchPages(credential.access_token, query) + const result: MetaFacebookPageResponse = { + pages: resp.data.map(item => ({ + id: item.id, + name: item.name, + location: `(${item.location?.street || ''} ${item.location?.city || ''} ${item.location?.state || ''} ${item.location?.country || ''})`, + })), + } + return result + } + + async fetchPostAttachments( + accountId: string, + postId: string, + ): Promise { + const credential = await this.authorizePage(accountId) + return await this.facebookAPIService.fetchPostAttachments(postId, credential.access_token) + } + + async deletePost( + accountId: string, + postId: string, + ): Promise { + const credential = await this.authorizePage(accountId) + await this.facebookAPIService.deletePost(postId, credential.access_token) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/instagram.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/instagram.service.ts new file mode 100644 index 000000000..1432f68fa --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/instagram.service.ts @@ -0,0 +1,261 @@ +import { Injectable, Logger } from '@nestjs/common' +import { RedisService } from '@yikart/redis' +import { getCurrentTimestamp } from '../../../common' +import { + ChunkedMediaUploadRequest, + CreateMediaContainerRequest, + CreateMediaContainerResponse, + IGCommentsResponse, + IGPostCommentsRequest, + InstagramInsightsRequest, + InstagramInsightsResponse, + InstagramMediaInsightsRequest, + InstagramUserInfoRequest, + InstagramUserInfoResponse, + InstagramUserPostRequest, + InstagramUserPostResponse, +} from '../../../libs/instagram/instagram.interfaces' +import { InstagramService as InstagramAPIService } from '../../../libs/instagram/instagram.service' +import { META_TIME_CONSTANTS, MetaRedisKeys } from './constants' +import { MetaPublishPlaintextCommentResponse, MetaUserOAuthCredential } from './meta.interfaces' + +@Injectable() +export class InstagramService { + private readonly redisService: RedisService + private readonly instagramAPIService: InstagramAPIService + private readonly logger = new Logger(InstagramService.name) + + constructor( + redisService: RedisService, + facebookAPIService: InstagramAPIService, + ) { + this.redisService = redisService + this.instagramAPIService = facebookAPIService + } + + private async authorize( + accountId: string, + ): Promise { + const credential = await this.redisService.getJson( + MetaRedisKeys.getAccessTokenKey('instagram', accountId), + ) + if (!credential) { + this.logger.error(`No access token found for accountId: ${accountId}`) + throw new Error(`No access token found for accountId: ${accountId}`) + } + const now = getCurrentTimestamp() + const tokenExpiredAt = now + credential.expires_in + const requestTime + = tokenExpiredAt - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + if (requestTime <= now) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const refreshedToken = await this.refreshOAuthCredential( + credential.access_token, + ) + if (!refreshedToken) { + this.logger.error( + `Failed to refresh access token for accountId: ${accountId}`, + ) + throw new Error(`Failed to refresh access token for accountId: ${accountId}`) + } + credential.access_token = refreshedToken.access_token + credential.expires_in = refreshedToken.expires_in + const saved = await this.saveOAuthCredential(accountId, credential, 'instagram') + if (!saved) { + this.logger.error( + `Failed to save refreshed access token for accountId: ${accountId}`, + ) + throw new Error(`Failed to save refreshed access token for accountId: ${accountId}`) + } + return credential + } + return credential + } + + private async refreshOAuthCredential(refresh_token: string) { + const credential + = await this.instagramAPIService.refreshOAuthCredential(refresh_token) + if (!credential) { + this.logger.error(`Failed to refresh access token`) + return null + } + return credential + } + + private async saveOAuthCredential( + accountId: string, + tokenInfo: MetaUserOAuthCredential, + platform: string, + ): Promise { + const now = getCurrentTimestamp() + const expireTime + = now + tokenInfo.expires_in - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + tokenInfo.expires_in = expireTime + return await this.redisService.setJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + tokenInfo, + expireTime, + ) + } + + async createMediaContainer( + accountId: string, + req: CreateMediaContainerRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.createMediaContainer( + credential.user_id, + credential.access_token, + req, + ) + } + + async chunkedMediaUploadRequest( + accountId: string, + req: ChunkedMediaUploadRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.chunkedMediaUploadRequest( + credential.access_token, + req, + ) + } + + async publishMediaContainer( + accountId: string, + igContainerId: string, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.publishMediaContainer( + credential.user_id, + credential.access_token, + igContainerId, + ) + } + + async getObjectInfo(accountId: string, objectId: string, pageId: string, fields?: string): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}, ${pageId}`) + return null + } + return await this.instagramAPIService.getObjectInfo(credential.access_token, objectId, fields) + } + + async getAccountInsights( + accountId: string, + query: InstagramInsightsRequest, + requestURL?: string, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.getAccountInsights( + credential.access_token, + credential.user_id, + query, + requestURL, + ) + } + + async getAccountInfo( + accountId: string, + query: InstagramUserInfoRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.getAccountInfo( + credential.user_id, + credential.access_token, + query, + ) + } + + async getMediaInsights( + accountId: string, + mediaId: string, + query: InstagramMediaInsightsRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.getMediaInsights( + credential.access_token, + mediaId, + query, + ) + } + + async getUserPosts( + accountId: string, + query: InstagramUserPostRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.getUserPosts( + credential.access_token, + credential.user_id, + query, + ) + } + + async fetchPostComments( + accountId: string, + postId: string, + query: IGPostCommentsRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.fetchPostComments( + credential.access_token, + postId, + query, + ) + } + + async fetchCommentReplies( + accountId: string, + commentId: string, + query: IGPostCommentsRequest, + ): Promise { + const credential = await this.authorize(accountId) + return await this.instagramAPIService.fetchCommentReplies( + credential.access_token, + commentId, + query, + ) + } + + async publishPlaintextComment( + accountId: string, + postId: string, + message: string, + ): Promise { + const credential = await this.authorize(accountId) + const resp = await this.instagramAPIService.publishComment( + credential.access_token, + postId, + message, + ) + const result: MetaPublishPlaintextCommentResponse = { + id: resp.id, + success: !!resp.id, + message: resp.id ? 'Comment published successfully' : 'Failed to publish comment', + } + return result + } + + async publishPlaintextCommentReply( + accountId: string, + commentId: string, + message: string, + ): Promise { + const credential = await this.authorize(accountId) + const resp = await this.instagramAPIService.publishSubComment( + credential.access_token, + commentId, + message, + ) + const result: MetaPublishPlaintextCommentResponse = { + id: resp.id, + success: !!resp.id, + message: resp.id ? 'Sub-comment published successfully' : 'Failed to publish sub-comment', + } + return result + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/linkedin.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/linkedin.service.ts new file mode 100644 index 000000000..058920e94 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/linkedin.service.ts @@ -0,0 +1,190 @@ +import { Injectable, Logger } from '@nestjs/common' +import { RedisService } from '@yikart/redis' +import { getCurrentTimestamp } from '../../../common' +import { LinkedInShareRequest, LinkedInUploadRequest, MemberNetworkVisibility, ShareMediaCategory, UploadRecipe } from '../../../libs/linkedin/linkedin.interface' +import { LinkedinService as LinkedinAPIService } from '../../../libs/linkedin/linkedin.service' +import { META_TIME_CONSTANTS, MetaRedisKeys } from './constants' +import { MetaUserOAuthCredential } from './meta.interfaces' + +@Injectable() +export class LinkedinService { + private readonly redisService: RedisService + private readonly linkedinAPIService: LinkedinAPIService + private readonly logger = new Logger(LinkedinService.name) + + constructor( + redisService: RedisService, + linkedinAPIService: LinkedinAPIService, + ) { + this.redisService = redisService + this.linkedinAPIService = linkedinAPIService + } + + private async authorize( + accountId: string, + ): Promise { + const credential = await this.redisService.getJson( + MetaRedisKeys.getAccessTokenKey('linkedin', accountId), + ) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const now = getCurrentTimestamp() + const tokenExpiredAt = now + credential.expires_in + const requestTime + = tokenExpiredAt - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + if (requestTime <= now) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const refreshedToken = await this.refreshOAuthCredential( + credential.access_token, + ) + if (!refreshedToken) { + this.logger.error( + `Failed to refresh access token for accountId: ${accountId}`, + ) + return null + } + credential.access_token = refreshedToken.access_token + credential.expires_in = refreshedToken.expires_in + const saved = await this.saveOAuthCredential(accountId, credential, 'linkedin') + if (!saved) { + this.logger.error( + `Failed to save refreshed access token for accountId: ${accountId}`, + ) + return null + } + return credential + } + return credential + } + + private async refreshOAuthCredential(refresh_token: string) { + const credential + = await this.linkedinAPIService.refreshOAuthCredential(refresh_token) + if (!credential) { + this.logger.error(`Failed to refresh access token`) + return null + } + return credential + } + + private async saveOAuthCredential( + accountId: string, + tokenInfo: MetaUserOAuthCredential, + platform: string, + ): Promise { + const expireTime + = tokenInfo.expires_in - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + return await this.redisService.setJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + tokenInfo, + expireTime, + ) + } + + public generateURN(accountId: string): string { + const uid = accountId.replace('linkedin_', '') + return `urn:li:person:${uid}` + } + + public async uploadMedia(accountId: string, src: string, recipe: UploadRecipe): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + throw new Error(`No valid credential for accountId: ${accountId}`) + } + const initMediaUploadReq: LinkedInUploadRequest = { + registerUploadRequest: { + recipes: [recipe], + owner: this.generateURN(accountId), + serviceRelationships: [ + { + relationshipType: 'OWNER', + identifier: 'urn:li:userGeneratedContent', + }, + ], + }, + } + this.logger.log(`Init upload request: ${JSON.stringify(initMediaUploadReq)}, accessToken: ${credential.access_token}`) + + const initUploadResp = await this.linkedinAPIService.initMediaUpload(credential.access_token, initMediaUploadReq) + this.logger.log(`Init upload response: ${JSON.stringify(initUploadResp)}`) + const dest = initUploadResp.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl + await this.linkedinAPIService.streamUpload(credential.access_token, src, dest) + return initUploadResp.value.asset + } + + async streamUpload(accountId: string, src: string, dest: string) { + const credential = await this.authorize(accountId) + if (!credential) { + throw new Error(`No valid credential for accountId: ${accountId}`) + } + return await this.linkedinAPIService.streamUpload(credential.access_token, src, dest) + } + + async publish(accountId: string, req: LinkedInShareRequest) { + const credential = await this.authorize(accountId) + if (!credential) { + throw new Error(`No valid credential for accountId: ${accountId}`) + } + return this.linkedinAPIService.createShare(credential.access_token, req) + } + + async createShare(accountId: string) { + const credential = await this.authorize(accountId) + if (!credential) { + throw new Error(`No valid credential for accountId: ${accountId}`) + } + + const initMediaUploadReq: LinkedInUploadRequest = { + registerUploadRequest: { + recipes: [UploadRecipe.VIDEO], + owner: this.generateURN(accountId), + serviceRelationships: [ + { + relationshipType: 'OWNER', + identifier: 'urn:li:userGeneratedContent', + }, + ], + }, + } + this.logger.log(`Init upload request: ${JSON.stringify(initMediaUploadReq)}, accessToken: ${credential.access_token}`) + + const initUploadResp = await this.linkedinAPIService.initMediaUpload(credential.access_token, initMediaUploadReq) + this.logger.log(`Init upload response: ${JSON.stringify(initUploadResp)}`) + const uploadURL = initUploadResp.value.uploadMechanism['com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'].uploadUrl + + await this.linkedinAPIService.streamUpload(credential.access_token, uploadURL, 'https://aitoearn.s3.ap-southeast-1.amazonaws.com/production/temp/uploads/9287ddb9-2180-4a3a-9cb2-91fadc1e50be.mp4') + const createShareReq: LinkedInShareRequest = { + author: this.generateURN(accountId), + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { text: 'Test share from Aitoearn' }, + shareMediaCategory: ShareMediaCategory.IMAGE, + media: [ + { + status: 'READY', + description: { text: 'Test image upload' }, + media: initUploadResp.value.asset, + title: { text: 'Aitoearn Image' }, + }, + ], + }, + }, + visibility: { 'com.linkedin.ugc.MemberNetworkVisibility': MemberNetworkVisibility.PUBLIC }, + } + this.logger.log(`Create share request: ${JSON.stringify(createShareReq)}`) + return await this.linkedinAPIService.createShare(credential.access_token, createShareReq) + } + + async deletePost(accountId: string, shareId: string) { + const credential = await this.authorize(accountId) + if (!credential) { + throw new Error('Failed to authorize') + } + return await this.linkedinAPIService.deletePost(credential.access_token, shareId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.controller.ts new file mode 100644 index 000000000..78e39c2c5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.controller.ts @@ -0,0 +1,295 @@ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { FacebookInsightsRequest, FacebookPagePostRequest, FacebookPostEdgesRequest, FacebookPublishedPostRequest } from '../../../libs/facebook/facebook.interfaces' +import { InstagramInsightsRequest, InstagramMediaInsightsRequest, InstagramUserPostRequest } from '../../../libs/instagram/instagram.interfaces' +import { ThreadsInsightsRequest, ThreadsPostsRequest } from '../../../libs/threads/threads.interfaces' +import { + CreateAccountAndSetAccessTokenDto, + GetAuthInfoDto, + GetAuthUrlDto, + PagesSelectionDto, + UserIdDto, +} from './dto/meta.dto' +import { FacebookService } from './facebook.service' +import { InstagramService } from './instagram.service' +import { LinkedinService } from './linkedin.service' +import { MetaService } from './meta.service' +import { ThreadsService } from './threads.service' + +@Controller() +export class MetaController { + constructor( + private readonly metaService: MetaService, + private readonly facebookService: FacebookService, + private readonly instagramService: InstagramService, + private readonly threadsService: ThreadsService, + private readonly linkedinService: LinkedinService, + ) { } + + // generate authorization URL + // @NatsMessagePattern('plat.meta.authUrl') + @Post('plat/meta/authUrl') + async generateAuthorizeURL(@Body() data: GetAuthUrlDto) { + return await this.metaService.generateAuthorizeURL( + data.userId, + data.platform, + data.scopes, + data.spaceId, + ) + } + + // check oauth task status + // @NatsMessagePattern('plat.meta.getAuthInfo') + @Post('plat/meta/getAuthInfo') + async getOAuth2TaskInfo(@Body() data: GetAuthInfoDto) { + return await this.metaService.getOAuth2TaskInfo(data.taskId) + } + + // @NatsMessagePattern('plat.meta.facebook.pages') + @Post('plat/meta/facebook/pages') + async getAuthInfo(@Body() data: UserIdDto) { + return await this.metaService.getFacebookPageList(data.userId) + } + + // @NatsMessagePattern('plat.meta.facebook.pages.selection') + @Post('plat/meta/facebook/pages/selection') + async selectFacebookPages(@Body() data: PagesSelectionDto) { + return await this.metaService.selectFacebookPages(data.userId, data.pageIds) + } + + // restFul API for get oauth authorize URL + @Get('oauth2/authorize_url/:platform') + async getOAuthAuthUri( + @Param('platform') platform: string, + @Query() + query: { + userId: string + scopes?: string[] + }, + ) { + return await this.metaService.generateAuthorizeURL( + query.userId, + platform, + query.scopes, + ) + } + + // restFul API for post oauth callback + @Get('auth/callback') + async postOAuth2CallbackByRestFul( + @Query() + query: { + code: string + state: string + }, + ) { + return await this.metaService.postOAuth2Callback(query.state, { + code: query.code, + state: query.state, + }) + } + + // NATS message pattern for post oauth callback + // get access token and create account + // @NatsMessagePattern('plat.meta.createAccountAndSetAccessToken') + @Post('plat/meta/createAccountAndSetAccessToken') + async postOAuth2Callback(@Body() data: CreateAccountAndSetAccessTokenDto) { + return await this.metaService.postOAuth2Callback(data.state, { + code: data.code, + state: data.state, + }) + } + + // @NatsMessagePattern('plat.meta.facebook.page.insights') + @Post('plat/meta/facebook/page/insights') + async getFacebookPageInsights( + @Body() data: { accountId: string, query: FacebookInsightsRequest }, + ) { + return await this.facebookService.getPageInsights( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.facebook.post.insights') + @Post('plat/meta/facebook/post/insights') + async getFacebookPostInsights( + @Body() data: { accountId: string, postId: string }, + ) { + return await this.facebookService.getPostInsights( + data.accountId, + data.postId, + ) + } + + // @NatsMessagePattern('plat.meta.facebook.page.published_posts') + @Post('plat/meta/facebook/page/published_posts') + async getFacebookPagePosts( + @Body() data: { accountId: string, query: FacebookPublishedPostRequest }, + ) { + return await this.facebookService.getPagePublishedPosts( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.instagram.account.info') + @Post('plat/meta/instagram/account/info') + async getInstagramAccountInfo( + @Body() data: { accountId: string, query?: any }, + ) { + return await this.instagramService.getAccountInfo( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.instagram.account.insights') + @Post('plat/meta/instagram/account/insights') + async getInstagramAccountInsights( + @Body() data: { accountId: string, query: InstagramInsightsRequest }, + ) { + return await this.instagramService.getAccountInsights( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.instagram.post.insights') + @Post('plat/meta/instagram/post/insights') + async getInstagramPostInsights( + @Body() data: { accountId: string, postId: string, query: InstagramMediaInsightsRequest }, + ) { + return await this.instagramService.getMediaInsights( + data.accountId, + data.postId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.threads.account.insights') + @Post('plat/meta/threads/account/insights') + async getThreadsAccountInsights( + @Body() data: { accountId: string, query: ThreadsInsightsRequest }, + ) { + return await this.threadsService.getAccountInsights( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.threads.post.insights') + @Post('plat/meta/threads/post/insights') + async getThreadsPostInsights( + @Body() data: { accountId: string, postId: string, query: ThreadsInsightsRequest }, + ) { + return await this.threadsService.getMediaInsights( + data.accountId, + data.postId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.facebook.page.posts') + @Post('plat/meta/facebook/page/posts') + async getFacebookPagePostsDetail( + @Body() data: { accountId: string, query: FacebookPagePostRequest }, + ) { + return await this.facebookService.getPostPosts( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.facebook.page.post.comments') + @Post('plat/meta/facebook/page/post/comments') + async getFacebookPagePostComments( + @Body() data: { accountId: string, postId: string, query: FacebookPostEdgesRequest }, + ) { + return await this.facebookService.getPostComments( + data.accountId, + data.postId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.instagram.user.posts') + @Post('plat/meta/instagram/user/posts') + async getInstagramUserPosts( + @Body() data: { accountId: string, query: InstagramUserPostRequest }, + ) { + return await this.instagramService.getUserPosts( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.threads.user.posts') + @Post('plat/meta/threads/user/posts') + async getThreadsUserPosts( + @Body() data: { accountId: string, query: ThreadsPostsRequest }, + ) { + return await this.threadsService.getUserPosts( + data.accountId, + data.query, + ) + } + + // @NatsMessagePattern('plat.meta.facebool.search.locations') + @Post('plat/meta/facebool/search/locations') + async searchFacebookLocations( + @Body() data: { accountId: string, keyword: string }, + ) { + return await this.facebookService.searchPages( + data.accountId, + data.keyword, + ) + } + + // @NatsMessagePattern('plat.meta.threads.search.locations') + @Post('plat/meta/threads/search/locations') + async searchThreadsLocations( + @Body() data: { accountId: string, keyword: string }, + ) { + return await this.threadsService.searchLocations( + data.accountId, + data.keyword, + ) + } + + // @NatsMessagePattern('plat.meta.accessTokenStatus') + @Post('plat/meta/accessTokenStatus') + async getAccessTokenStatus(@Body() data: { accountId: string, platform: string }) { + return await this.metaService.getAccessTokenStatus(data.accountId, data.platform) + } + + @Post('facebook/posts/attachments') + async getFacebookPostAttachments( + @Body() data: { postId: string, accountId: string }, + ) { + return await this.facebookService.fetchPostAttachments(data.accountId, data.postId) + } + + @Delete('facebook/posts/:postId') + async deleteFacebookPost( + @Param('postId') postId: string, + @Body() data: { accountId: string }, + ) { + return await this.facebookService.deletePost(data.accountId, postId) + } + + @Delete('threads/posts/:postId') + async deleteThreadsPost( + @Param('postId') postId: string, + @Body() data: { accountId: string }, + ) { + return await this.threadsService.deletePost(postId, data.accountId) + } + + @Delete('linkedin/:accountId/posts/:postId') + async deleteLinkedinPost( + @Param('postId') postId: string, + @Param('accountId') accountId: string, + ) { + return await this.linkedinService.deletePost(accountId, postId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.interfaces.ts new file mode 100644 index 000000000..d5f996b4c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.interfaces.ts @@ -0,0 +1,112 @@ +export interface MetaOAuth2TaskInfo { + pkce: boolean + platform: string + state: string + codeVerifier?: string + userId: string + status: 0 | 1 + accountId?: string + spaceId?: string +} + +export interface MetaOAuth2TaskStatus extends Partial { + state: string + status: 0 | 1 +} + +export interface MetaOAuthShortLivedCredential { + access_token: string +} + +export interface MetaOAuthLongLivedCredential + extends MetaOAuthShortLivedCredential { + token_type: string + expires_in: number +} + +export interface OAuth2Credential { + access_token: string + expires_in: number + refresh_token: string + token_type: string + refresh_token_expires_in: string +} + +export interface MetaUserOAuthCredential extends OAuth2Credential { + user_id: string +} +export interface FacebookPageInfo { + id: string + name: string + access_token: string + category: string + expires_in: number +} + +export interface FacebookPageCredentials extends FacebookPageInfo { + facebook_user_id: string + spaceId?: string +} + +export interface FacebookPage { + id: string + name: string + profile_picture_url?: string +} + +export interface FacebookAccountResponse { + data: FacebookPageInfo[] +} + +export interface MetaObjectInfo { + id: string + status: string +} + +export interface SelectFacebookPagesResponse { + success: boolean + message?: string + selectedPageIds: string[] +} + +export interface MetaPostComment { + id: string + text: string + createdTime: string + commenterUsername: string + commenterAvatarUrl?: string + hasSubComments: boolean +} + +export interface MetaPostCommentsResponse { + comments: MetaPostComment[] + cursor: { + before: string + after: string + } +} + +export interface MetaPublishPlaintextCommentResponse { + id: string + success: boolean + message: string +} + +export interface MetaLocation { + id: string + label: string +} + +export interface MetaLocationSearchResponse { + locations: MetaLocation[] +} + +export interface MetaFacebookPage { + id: string + name: string + location?: string +} + +export interface MetaFacebookPageResponse { + pages: MetaFacebookPage[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.module.ts new file mode 100644 index 000000000..d69f26477 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { FacebookService as FacebookAPIService } from '../../../libs/facebook/facebook.service' +import { InstagramService as InstagramAPIService } from '../../../libs/instagram/instagram.service' +import { LinkedinService as LinkedinAPIService } from '../../../libs/linkedin/linkedin.service' +import { ThreadsService as ThreadsAPIService } from '../../../libs/threads/threads.service' +import { FacebookService } from './facebook.service' +import { InstagramService } from './instagram.service' +import { LinkedinService } from './linkedin.service' +import { MetaController } from './meta.controller' +import { MetaService } from './meta.service' +import { ThreadsService } from './threads.service' + +@Module({ + controllers: [MetaController], + providers: [ + MetaService, + FacebookService, + InstagramService, + ThreadsService, + LinkedinService, + FacebookAPIService, + InstagramAPIService, + ThreadsAPIService, + LinkedinAPIService, + ], + exports: [MetaService, FacebookService, InstagramService, ThreadsService, LinkedinService], +}) +export class MetaModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.service.ts new file mode 100644 index 000000000..6fea12075 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/meta.service.ts @@ -0,0 +1,685 @@ +import { createHash, randomBytes } from 'node:crypto' +import { Injectable, Logger } from '@nestjs/common' +import { + AccountStatus, + AccountType, + NewAccount, +} from '@yikart/aitoearn-server-client' +import { RedisService } from '@yikart/redis' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { getCurrentTimestamp } from '../../../common' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { Account } from '../../../libs/database/schema/account.schema' +import { + FacebookPageDetailRequest, + FacebookPageDetailResponse, +} from '../../../libs/facebook/facebook.interfaces' +import { + META_TIME_CONSTANTS, + metaOAuth2ConfigMap, + MetaRedisKeys, +} from './constants' +import { + FacebookAccountResponse, + FacebookPage, + FacebookPageCredentials, + MetaOAuth2TaskInfo, + MetaOAuth2TaskStatus, + MetaUserOAuthCredential, + OAuth2Credential, + SelectFacebookPagesResponse, +} from './meta.interfaces' + +@Injectable() +export class MetaService { + private prefix = 'meta' + private readonly redisService: RedisService + private readonly accountService: AccountService + private readonly logger = new Logger(MetaService.name) + + constructor(redisService: RedisService, accountService: AccountService) { + this.redisService = redisService + this.accountService = accountService + } + + async generateAuthorizeURL( + userId: string, + platform: string, + oAuth2Scopes?: string[], + spaceId = '', + ) { + this.logger.log( + `Generating authorize URL for userId: ${userId}, platform: ${platform}}`, + ) + const scopes + = oAuth2Scopes + || config.oauth[platform].scopes + || metaOAuth2ConfigMap[platform].defaultScopes + const state = randomBytes(32).toString('hex') + const scopeSeparator = metaOAuth2ConfigMap[platform].scopesSeparator + const params = new URLSearchParams({ + client_id: config.oauth[platform].clientId, + redirect_uri: config.oauth[platform].redirectUri, + response_type: 'code', + state, + }) + if (scopes.length > 1) { + params.append('scope', scopes.join(scopeSeparator)) + } + else { + params.append('scope', scopes[0]) + } + if (config.oauth[platform].configId) { + params.append('config_id', config.oauth[platform].configId) + } + const pkceEnabled = metaOAuth2ConfigMap[platform].pkce + if (pkceEnabled) { + params.append('code_challenge_method', 'S256') + const codeVerifier = randomBytes(64).toString('hex') + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url') + params.append('code_challenge', codeChallenge) + } + + const authorizeURL = new URL(metaOAuth2ConfigMap[platform].authURL) + authorizeURL.search = params.toString() + this.logger.debug(`Generated meta auth URL: ${authorizeURL.toString()}`) + + const success = await this.redisService.setJson( + MetaRedisKeys.getAuthTaskKey(state), + { + state, + status: 0, + userId, + pkce: false, + platform, + spaceId, + }, + META_TIME_CONSTANTS.AUTH_TASK_EXPIRE, + ) + return success + ? { url: authorizeURL.toString(), taskId: state, state } + : null + } + + async getOAuth2TaskInfo(state: string) { + const result = await this.redisService.getJson( + MetaRedisKeys.getAuthTaskKey(state), + ) + if (!result) { + this.logger.warn(`OAuth2 task not found for state: ${state}`) + return { + state, + status: 0, + } + } + return result + } + + async getOAuthCredential( + code: string, + info: MetaOAuth2TaskInfo, + ): Promise { + const platform = info.platform.toLowerCase() + const pkceEnabled = metaOAuth2ConfigMap[platform].pkce + const accessTokenURL = metaOAuth2ConfigMap[platform].accessTokenURL + const longLivedAccessTokenURL + = metaOAuth2ConfigMap[platform].longLivedAccessTokenURL || '' + const redirectURI = config.oauth[platform].redirectUri + const clientId = config.oauth[platform].clientId + const clientSecret = config.oauth[platform].clientSecret + const requestAccessTokenMethod + = metaOAuth2ConfigMap[platform].requestAccessTokenMethod + + const params = new URLSearchParams({ + client_id: clientId, + grant_type: 'authorization_code', + code, + redirect_uri: redirectURI, + }) + if (pkceEnabled) { + params.append('code_verifier', info.codeVerifier || '') + } + else { + params.append('client_secret', clientSecret) + } + this.logger.log( + `Requesting access token with params: ${params.toString()}`, + ) + const reqConfig: AxiosRequestConfig = { + method: requestAccessTokenMethod, + url: accessTokenURL, + } + + if (requestAccessTokenMethod === 'POST') { + reqConfig.headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + } + reqConfig.data = params.toString() + } + else { + reqConfig.params = params + } + this.logger.log( + `Requesting access token with config: ${JSON.stringify(reqConfig)}`, + ) + const response: AxiosResponse + = await axios.request(reqConfig) + + this.logger.log(`Access token response: ${JSON.stringify(response.data)}`) + if (longLivedAccessTokenURL) { + const llAccessTokenReqParamsMap + = metaOAuth2ConfigMap[platform].longLivedParamsMap + const lParams: Record = { + client_id: clientId, + client_secret: clientSecret, + } + const accessTokenKey + = llAccessTokenReqParamsMap?.access_token || 'access_token' + lParams[accessTokenKey] = response.data.access_token + lParams['grant_type'] + = metaOAuth2ConfigMap[platform].longLivedGrantType || 'fb_exchange_token' + + const longLivedAccessTokenReqParams = new URLSearchParams(lParams) + const llTokenResponse: AxiosResponse = await axios.get( + longLivedAccessTokenURL, + { + params: longLivedAccessTokenReqParams, + }, + ) + const credential = llTokenResponse.data + if (!credential.expires_in) { + credential.expires_in + = META_TIME_CONSTANTS.FACEBOOK_LONG_LIVED_TOKEN_DEFAULT_EXPIRE + } + return credential + } + else { + return response.data + } + } + + async getPageDetails( + pageId: string, + pageAccessToken: string, + query: FacebookPageDetailRequest, + ): Promise { + try { + const url = `${metaOAuth2ConfigMap.facebook.apiBaseUrl}/${pageId}` + const config: AxiosRequestConfig = { + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + const response: AxiosResponse + = await axios.get(url, config) + return response.data + } + catch (error) { + if (error.response) { + this.logger.error( + `Error fetching page details pageId: ${pageId}, req: ${JSON.stringify(query)}: ${error.response.status} - ${JSON.stringify(error.response.data)}`, + ) + } + throw new Error( + `Error fetching page details, pageId: ${pageId}, req: ${JSON.stringify(query)}`, + ) + } + } + + async getUserProfile( + accessToken: string, + platform: string, + ): Promise> { + const userProfileURL = metaOAuth2ConfigMap[platform].userProfileURL + const url = new URL(userProfileURL) + const config = { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + const response: AxiosResponse> = await axios.get( + url.toString(), + config, + ) + return response.data + } + + async getFacebookPageList(userId: string): Promise { + const key = MetaRedisKeys.getUserPageListKey('facebook', userId) + const pages = await this.redisService.getJson(key) + if (pages) { + return pages + } + return [] + } + + async selectFacebookPages( + userId: string, + pageIds: string[], + ): Promise { + const result: SelectFacebookPagesResponse = { + success: false, + message: '', + selectedPageIds: [], + } + const key = MetaRedisKeys.getUserPageListKey('facebook', userId) + const pages = await this.redisService.getJson(key) + if (!pages || pages.length === 0) { + this.logger.warn(`No Facebook pages found for userId: ${userId}`) + result.message = 'No Facebook pages found for the user.' + return result + } + const pageMap = new Map() + for (const page of pages) { + pageMap.set(page.id, page) + } + + for (const pageId of pageIds) { + const page = pageMap.get(pageId) + if (!page) { + this.logger.warn( + `Page ID ${pageId} not found in user's Facebook pages`, + ) + result.message = `Page ID ${pageId} not found in user's Facebook pages` + return result + } + const pageCredentialKey = MetaRedisKeys.getUserPageAccessTokenKey( + 'facebook', + pageId, + ) + const pageCredential + = await this.redisService.getJson( + pageCredentialKey, + ) + if (!pageCredential) { + result.message = `Page access token not found for userId: ${userId}, pageId: ${pageId}` + return result + } + const accountInfo = await this.createAccount( + userId, + AccountType.FACEBOOK, + { + id: pageId, + groupId: pageCredential.spaceId, + name: page.name, + profile_picture_url: page.profile_picture_url, + }, + ) + + if (!accountInfo) { + this.logger.error( + `Failed to create account for userId: ${userId}, pageId: ${pageId}`, + ) + result.message = `Failed to create account for userId: ${userId}, pageId: ${pageId}` + return result + } + const newPageCredentialKey = MetaRedisKeys.getUserPageAccessTokenKey( + 'facebook', + accountInfo.id, + ) + await this.redisService.setJson(newPageCredentialKey, pageCredential) + await this.redisService.del(pageCredentialKey) + const previousFacebookCredentialKey = MetaRedisKeys.getAccessTokenKey( + 'facebook', + userId, + ) + const newFacebookCredentialKey = MetaRedisKeys.getAccessTokenKey( + 'facebook', + pageCredential.facebook_user_id, + ) + const facebookCredential + = await this.redisService.getJson( + previousFacebookCredentialKey, + ) + if (facebookCredential) { + await this.redisService.setJson( + newFacebookCredentialKey, + facebookCredential, + ) + await this.redisService.del(previousFacebookCredentialKey) + } + else { + this.logger.warn( + `No Facebook user credential found for userId: ${userId} when selecting pageId: ${pageId}`, + ) + throw new Error( + `No Facebook user credential found for userId: ${userId} when selecting pageId: ${pageId}`, + ) + } + result.selectedPageIds.push(pageId) + } + result.success = true + result.message = 'Selected Facebook pages successfully.' + return result + } + + async getFacebookAccount(accessToken: string, pageAccountURL: string) { + try { + const response: AxiosResponse = await axios.get( + pageAccountURL, + { + params: { + access_token: accessToken, + }, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + const data = response.data.data || [] + return data + } + catch (error) { + if (error.response) { + this.logger.error( + `Error fetching user account, status: ${error.response.status}, response: ${error.response.data}`, + ) + } + this.logger.error( + `Failed to fetch user account: ${error.message}, stack: ${error.stack}`, + ) + return [] + } + } + + async postOAuth2Callback( + state: string, + authData: { code: string, state: string }, + ) { + const { code } = authData + + const authTaskInfo = await this.redisService.getJson( + MetaRedisKeys.getAuthTaskKey(state), + ) + if (!authTaskInfo) { + this.logger.error(`OAuth task not found for state: ${state}`) + return { + status: 0, + message: '授权任务不存在或已过期', + } + } + + void this.redisService.expire( + MetaRedisKeys.getAuthTaskKey(state), + META_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + + try { + // get access token + const credential = await this.getOAuthCredential(code, authTaskInfo) + if (!credential) { + this.logger.error(`Failed to get access token for state: ${state}`) + return { + status: 0, + message: '获取访问令牌失败', + } + } + this.logger.log( + `Access token retrieved for userId: ${authTaskInfo.userId}, platform: ${authTaskInfo.platform}, credential: ${JSON.stringify(credential)}`, + ) + + // fetch user profile + const userProfile = await this.getUserProfile( + credential.access_token, + authTaskInfo.platform, + ) + userProfile.groupId = authTaskInfo.spaceId + + if (metaOAuth2ConfigMap[authTaskInfo.platform].pageAccountURL) { + this.logger.log( + `Fetching Facebook pages for userId: ${authTaskInfo.userId}, platform: ${authTaskInfo.platform}`, + ) + const pageAccounts = await this.getFacebookAccount( + credential.access_token, + metaOAuth2ConfigMap[authTaskInfo.platform].pageAccountURL, + ) + this.logger.log( + `Fetched ${pageAccounts.length} pages for userId: ${authTaskInfo.userId}, platform: ${authTaskInfo.platform}`, + ) + if (pageAccounts.length === 0) { + return { + status: 0, + message: + 'No Facebook pages found for the user. Please ensure you have at least one Facebook Page and the necessary permissions.', + } + } + if (pageAccounts.length > 0) { + const pages: FacebookPage[] = [] + for (const pageAccount of pageAccounts) { + const pageDetail = await this.getPageDetails( + pageAccount.id, + pageAccount.access_token, + { + fields: 'picture', + }, + ) + const expiredTime + = getCurrentTimestamp() + + credential.expires_in + - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + await this.redisService.setJson( + MetaRedisKeys.getUserPageAccessTokenKey( + authTaskInfo.platform, + pageAccount.id, + ), + { + ...pageAccount, + facebook_user_id: userProfile.id, + expires_in: expiredTime, + spaceId: authTaskInfo.spaceId, + } as FacebookPageCredentials, + ) + pages.push({ + id: pageAccount.id, + name: pageAccount.name, + profile_picture_url: pageDetail?.picture?.data?.url, + }) + } + await this.redisService.setJson( + MetaRedisKeys.getUserPageListKey( + authTaskInfo.platform, + authTaskInfo.userId, + ), + pages, + ) + } + const userCredential = { + ...credential, + user_id: userProfile.id || userProfile.sub, + } as MetaUserOAuthCredential + + const tokenSaved = await this.saveOAuthCredential( + authTaskInfo.userId, + userCredential, + authTaskInfo.platform, + ) + + if (!tokenSaved) { + this.logger.error( + `Failed to save access token for accountId: ${'accountInfo.id'}`, + ) + return { + status: 0, + message: '保存访问令牌失败', + } + } + const taskUpdated = await this.updateAuthTaskStatus( + state, + authTaskInfo, + authTaskInfo.userId, + ) + + if (!taskUpdated) { + this.logger.error( + `Failed to update auth task status for state: ${state}, accountId: ${authTaskInfo.userId}`, + ) + return { + status: 0, + message: '更新授权任务状态失败', + } + } + return { + status: 1, + message: '授权成功', + accountId: authTaskInfo.userId, + } + } + + const accountType = authTaskInfo.platform.toLowerCase() as AccountType + const accountInfo = await this.createAccount( + authTaskInfo.userId, + accountType, + userProfile, + ) + if (!accountInfo) { + this.logger.error( + `Failed to create account for userId: ${authTaskInfo.userId}, twitterId: ${userProfile.id}`, + ) + return null + } + + const userCredential = { + ...credential, + user_id: userProfile.id || userProfile.sub, + } as MetaUserOAuthCredential + + const tokenSaved = await this.saveOAuthCredential( + accountInfo.id, + userCredential, + authTaskInfo.platform, + ) + + if (!tokenSaved) { + this.logger.error( + `Failed to save access token for accountId: ${'accountInfo.id'}`, + ) + return null + } + const taskUpdated = await this.updateAuthTaskStatus( + state, + authTaskInfo, + accountInfo.id, + ) + + if (!taskUpdated) { + this.logger.error( + `Failed to update auth task status for state: ${state}, accountId: ${accountInfo.id}`, + ) + return null + } + return accountInfo + } + catch (error) { + if (error.response) { + this.logger.error( + `Error in OAuth2 callback: ${error.response.status} - ${JSON.stringify(error.response.data)}`, + ) + } + this.logger.error( + `Error processing OAuth2 callback for state: ${state}, code: ${code}, error: ${error.message}`, + error.stack, + ) + return null + } + } + + private async createAccount( + userId: string, + accountType: AccountType, + userProfile: Record, + ): Promise { + this.logger.log( + `Creating account for userId: ${userId}, platform: ${accountType}, userProfile: ${JSON.stringify(userProfile)}`, + ) + const newAccountData = new NewAccount({ + userId, + type: accountType, + uid: userProfile.id || userProfile.sub, + account: userProfile.username || userProfile.name, + avatar: + userProfile.profile_picture_url + || userProfile.threads_profile_picture_url + || userProfile.picture?.data?.url + || userProfile.picture + || '', + nickname: userProfile.username || userProfile.name, + lastStatsTime: new Date(), + loginTime: new Date(), + groupId: userProfile.groupId || '', + status: AccountStatus.NORMAL, + }) + + const accountInfo = await this.accountService.createAccount( + userId, + { + type: accountType, + uid: userProfile.id || userProfile.sub, + }, + newAccountData, + ) + if (!accountInfo) { + this.logger.error( + `Failed to create account for userId: ${userId}, twitterId: ${userProfile.id}`, + ) + return null + } + return accountInfo + } + + private async saveOAuthCredential( + accountId: string, + tokenInfo: MetaUserOAuthCredential, + platform: string, + ): Promise { + const now = getCurrentTimestamp() + const expireTime + = now + tokenInfo.expires_in - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + tokenInfo.expires_in = expireTime + return await this.redisService.setJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + tokenInfo, + ) + } + + private async updateAuthTaskStatus( + state: string, + authTaskInfo: MetaOAuth2TaskInfo, + accountId: string, + ): Promise { + authTaskInfo.status = 1 + authTaskInfo.accountId = accountId + + return await this.redisService.setJson( + MetaRedisKeys.getAuthTaskKey(state), + authTaskInfo, + META_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + } + + async getAccessTokenStatus( + accountId: string, + platform: string, + ): Promise { + if (platform === 'facebook') { + const pageCredential + = await this.redisService.getJson( + MetaRedisKeys.getUserPageAccessTokenKey('facebook', accountId), + ) + if (!pageCredential) { + return 0 + } + return pageCredential.expires_in > getCurrentTimestamp() ? 1 : 0 + } + const credential = await this.redisService.getJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + ) + if (!credential) { + return 0 + } + return credential.expires_in > getCurrentTimestamp() ? 1 : 0 + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/threads.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/threads.service.ts new file mode 100644 index 000000000..d7f577a06 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/meta/threads.service.ts @@ -0,0 +1,292 @@ +import { Injectable, Logger } from '@nestjs/common' +import { RedisService } from '@yikart/redis' +import { getCurrentTimestamp } from '../../../common' +import { + publicProfileResponse, + ThreadsContainerRequest, + ThreadsInsightsRequest, + ThreadsInsightsResponse, + ThreadsObjectCommentsRequest, + ThreadsObjectCommentsResponse, + ThreadsPostResponse, + ThreadsPostsRequest, + ThreadsPostsResponse, + ThreadsSearchLocationRequest, +} from '../../../libs/threads/threads.interfaces' +import { ThreadsService as ThreadsAPIService } from '../../../libs/threads/threads.service' +import { META_TIME_CONSTANTS, MetaRedisKeys } from './constants' +import { MetaLocation, MetaUserOAuthCredential } from './meta.interfaces' + +@Injectable() +export class ThreadsService { + private readonly redisService: RedisService + private readonly threadsAPIService: ThreadsAPIService + private readonly logger = new Logger(ThreadsService.name) + + constructor( + redisService: RedisService, + threadsAPIService: ThreadsAPIService, + ) { + this.redisService = redisService + this.threadsAPIService = threadsAPIService + } + + private async authorize( + accountId: string, + ): Promise { + const credential = await this.redisService.getJson( + MetaRedisKeys.getAccessTokenKey('threads', accountId), + ) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const now = getCurrentTimestamp() + if (now >= credential.expires_in) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const refreshedToken = await this.refreshOAuthCredential( + credential.access_token, + ) + if (!refreshedToken) { + this.logger.error( + `Failed to refresh access token for accountId: ${accountId}`, + ) + return null + } + credential.access_token = refreshedToken.access_token + credential.expires_in = refreshedToken.expires_in + const saved = await this.saveOAuthCredential(accountId, credential, 'threads') + if (!saved) { + this.logger.error( + `Failed to save refreshed access token for accountId: ${accountId}`, + ) + return null + } + return credential + } + return credential + } + + private async refreshOAuthCredential(refresh_token: string) { + const credential + = await this.threadsAPIService.refreshOAuthCredential(refresh_token) + if (!credential) { + this.logger.error(`Failed to refresh access token`) + return null + } + return credential + } + + private async saveOAuthCredential( + accountId: string, + tokenInfo: MetaUserOAuthCredential, + platform: string, + ): Promise { + const now = getCurrentTimestamp() + const expireTime + = now + tokenInfo.expires_in - META_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + tokenInfo.expires_in = expireTime + return await this.redisService.setJson( + MetaRedisKeys.getAccessTokenKey(platform, accountId), + tokenInfo, + ) + } + + async createItemContainer( + accountId: string, + req: ThreadsContainerRequest, + ): Promise { + try { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.createItemContainer( + credential.user_id, + credential.access_token, + req, + ) + } + catch (error) { + if (error.response) { + this.logger.error(`Error response: ${JSON.stringify(error.response.data)}`) + } + return null + } + } + + async publishPost( + accountId: string, + igContainerId: string, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.publishPost( + credential.user_id, + credential.access_token, + igContainerId, + ) + } + + async getObjectInfo(accountId: string, objectId: string, pageId: string, fields?: string): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId} ${pageId}`) + return null + } + return await this.threadsAPIService.getObjectInfo(credential.access_token, objectId, fields) + } + + async getAccountInsights( + accountId: string, + query: ThreadsInsightsRequest, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.getAccountInsights( + credential.user_id, + credential.access_token, + query, + ) + } + + async getMediaInsights( + accountId: string, + mediaId: string, + query: ThreadsInsightsRequest, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.getMediaInsights( + mediaId, + credential.access_token, + query, + ) + } + + async getPublicProfile( + accountId: string, + username: string, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.getPublicProfile( + credential.access_token, + username, + ) + } + + async getUserPosts( + accountId: string, + query: ThreadsPostsRequest, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.getAccountAllPosts( + credential.user_id, + credential.access_token, + query, + ) + } + + async fetchObjectComments( + accountId: string, + objectId: string, + query: ThreadsObjectCommentsRequest, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + return await this.threadsAPIService.fetchObjectComments( + objectId, + credential.access_token, + query, + ) + } + + async publishPlaintextComment( + accountId: string, + objectId: string, + message: string, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + const containerReq: ThreadsContainerRequest = { + text: message, + reply_to_id: objectId, + media_type: 'TEXT', + } + const createContainerResp = await this.threadsAPIService.createItemContainer( + credential.user_id, + credential.access_token, + containerReq, + ) + if (!createContainerResp || !createContainerResp.id) { + this.logger.error(`Failed to create comment container for objectId: ${objectId}`) + return null + } + return await this.threadsAPIService.publishPost( + credential.user_id, + credential.access_token, + createContainerResp.id, + ) + } + + async searchLocations( + accountId: string, + keyword: string, + ): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.error(`No valid access token found for accountId: ${accountId}`) + return null + } + const query: ThreadsSearchLocationRequest = { + query: keyword, + fields: 'id, name, address, city, country, latitude, longitude, postal_code', + } + const response = await this.threadsAPIService.searchLocations( + credential.access_token, + query, + ) + if (!response) { + this.logger.error(`Failed to search locations for keyword: ${keyword}`) + return null + } + const result = response.data.map(loc => ({ + id: loc.id, + label: `${loc.name} - ${loc.address || ''} ${loc.city || ''} ${loc.country || ''}`, + })) + return result + } + + async deletePost( + postId: string, + accessToken: string, + ): Promise { + return await this.threadsAPIService.deletePost(postId, accessToken) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/dto/pinterest.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/dto/pinterest.dto.ts new file mode 100644 index 000000000..05f2ca034 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/dto/pinterest.dto.ts @@ -0,0 +1,149 @@ +import { Expose } from 'class-transformer' +import { + IsArray, + IsObject, + IsOptional, + IsString, +} from 'class-validator' +/* + * @Author: white + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: white + * @Description: 用户 + */ +import { Country, Currency, SourceType } from '../../../../libs/pinterest/common' + +export class CreateAccountBodyDto { + @IsString({ message: '国家' }) + @IsOptional() + @Expose() + readonly country: Country + + @IsString({ message: '货币' }) + @IsOptional() + @Expose() + readonly currency: Currency + + @IsString({ message: '名称' }) + @IsOptional() + @Expose() + readonly name: string + + @IsString({ message: '所属用户' }) + @IsOptional() + @Expose() + readonly owner_user_id: string +} + +export class CreateBoardBodyDto { + @IsString({ message: '名称' }) + @Expose() + readonly name: string + + @IsString({ message: '用户id' }) + @Expose() + @IsOptional() + readonly accountId?: string +} + +export class MediaSource { + @IsString({ message: '媒体类型' }) + @Expose() + readonly source_type: SourceType + + @IsString({ message: '封面地址' }) + @Expose() + @IsOptional() + readonly cover_image_url: string + + @IsString({ message: '图片或者视频地址' }) + @Expose() + @IsOptional() + readonly url: string +} + +export class CreatePinBodyItemDto { + @IsString({ message: '地址' }) + @Expose() + @IsOptional() + readonly url: string + + @IsString({ message: '标题' }) + @Expose() + @IsOptional() + readonly title: string + + @IsString({ message: '描述' }) + @Expose() + @IsOptional() + readonly description: string + + @IsString({ message: '链接' }) + @Expose() + @IsOptional() + readonly link: string +} + +export class CreatePinBodyDto { + @IsString({ message: '此 Pin 所属的板块。' }) + @Expose() + readonly board_id: string + + @IsString({ message: '用户id' }) + @Expose() + @IsOptional() + readonly accountId?: string + + @IsString({ message: '点击连接' }) + @IsOptional() + @Expose() + readonly link: string + + @IsString({ message: '标题' }) + @IsOptional() + @Expose() + readonly title: string + + @IsString({ message: '描述' }) + @Expose() + @IsOptional() + readonly description: string + + @IsString({ message: 'RGB表示的颜色 主引脚颜色。十六进制数,例如“#6E7874"' }) + @Expose() + @IsOptional() + readonly dominant_color: string + + @IsString({ message: 'alt_text' }) + @Expose() + @IsOptional() + readonly alt_text: string + + @IsObject({ message: '媒体来源' }) + @Expose() + @IsOptional() + readonly media_source: MediaSource + + @IsString({ message: '地址' }) + @Expose() + @IsOptional() + readonly url: string + + @IsArray({ message: '媒体来源' }) + @Expose() + @IsOptional() + readonly items: CreatePinBodyItemDto[] +} + +export class WebhookDto { + @IsString({ message: 'code' }) + @Expose() + @IsOptional() + readonly code: string + + @IsString({ message: 'state' }) + @Expose() + @IsOptional() + readonly state: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.controller.ts new file mode 100644 index 000000000..b4867c4e2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.controller.ts @@ -0,0 +1,110 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { CreateBoardBodyDto, CreatePinBodyDto, WebhookDto } from './dto/pinterest.dto' +import { PinterestService } from './pinterest.service' + +@Controller() +export class PinterestController { + constructor( + private readonly pinterestService: PinterestService, + ) { + } + + // 创建board + // @NatsMessagePattern('plat.pinterest.createBoard') + @Post('plat/pinterest/createBoard') + createBoard(@Body() data: CreateBoardBodyDto) { + return this.pinterestService.createBoard(data) + } + + // board list + // @NatsMessagePattern('plat.pinterest.getBoardList') + @Post('plat/pinterest/getBoardList') + getBoardList(@Body() data: { accountId: string }) { + return this.pinterestService.getBoardList(data.accountId) + } + + // 获取单个board + // @NatsMessagePattern('plat.pinterest.getBoardById') + @Post('plat/pinterest/getBoardById') + getBoardById(@Body() data: { id: string, accountId: string }) { + return this.pinterestService.getBoardById(data.id, data.accountId) + } + + // 删除单个board + // @NatsMessagePattern('plat.pinterest.delBoardById') + @Post('plat/pinterest/delBoardById') + delBoardById(@Body() data: { id: string, accountId: string }) { + return this.pinterestService.delBoardById(data.id, data.accountId) + } + + // 创建pin + + // @NatsMessagePattern('plat.pinterest.createPin') + @Post('plat/pinterest/createPin') + createPin(@Body() data: CreatePinBodyDto) { + return this.pinterestService.createPin(data) + } + + // 获取pin + // @NatsMessagePattern('plat.pinterest.getPinById') + @Post('plat/pinterest/getPinById') + getPinById(@Body() data: { id: string, accountId: string }) { + return this.pinterestService.getPinById(data.id, data.accountId) + } + + // 获取pin + // @NatsMessagePattern('plat.pinterest.getPinList') + @Post('plat/pinterest/getPinList') + getPinList(@Body() data: { accountId: string }) { + return this.pinterestService.getPinList(data.accountId) + } + + // 删除pin + // @NatsMessagePattern('plat.pinterest.delPinById') + @Post('plat/pinterest/delPinById') + delPinById(@Body() data: { id: string, accountId: string }) { + return this.pinterestService.delPinById(data.id, data.accountId) + } + + // 上传视频获取视频id + // @NatsMessagePattern('plat.pinterest.uploadVideo') + @Post('plat/pinterest/uploadVideo') + uploadVideo(@Body() data: { videoUrl: string, accountId: string }) { + return this.pinterestService.uploadVideo(data.videoUrl, data.accountId) + } + + // 获取授权地址 + // @NatsMessagePattern('plat.pinterest.getAuth') + @Post('plat/pinterest/getAuth') + getAuth(@Body() data: { userId: string, spaceId: string }) { + return this.pinterestService.getAuth(data.userId, data.spaceId) + } + + // 授权地址回调 + // @NatsMessagePattern('plat.pinterest.authWebhook') + @Post('plat/pinterest/authWebhook') + authWebhook(@Body() data: WebhookDto) { + return this.pinterestService.authWebhook(data) + } + + // 查询授权结果 + // 获取授权地址 + // @NatsMessagePattern('plat.pinterest.checkAuth') + @Post('plat/pinterest/checkAuth') + checkAuth(@Body() data: { taskId: string }) { + return this.pinterestService.checkAuth(data.taskId) + } + + // 获取用户信息 + // @NatsMessagePattern('plat.pinterest.getUserInfo') + @Post('plat/pinterest/getUserInfo') + getUserInfo(@Body() data: { accountId: string }) { + return this.pinterestService.getUserInfo(data.accountId) + } + + // @NatsMessagePattern('plat.pinterest.accessTokenStatus') + @Post('plat/pinterest/accessTokenStatus') + async getAccessTokenStatus(@Body() data: { accountId: string }) { + return await this.pinterestService.getAccessTokenStatus(data.accountId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.module.ts new file mode 100644 index 000000000..14a0558e8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { PinterestApiModule } from '../../../libs/pinterest/pinterestApi.module' +import { PinterestController } from './pinterest.controller' +import { PinterestService } from './pinterest.service' + +@Module({ + imports: [ + PinterestApiModule, + ], + controllers: [PinterestController], + providers: [PinterestService], + exports: [PinterestService], +}) +export class PinterestModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.service.ts new file mode 100644 index 000000000..c25ef0d25 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/pinterest/pinterest.service.ts @@ -0,0 +1,335 @@ +import * as fs from 'node:fs' +import { Injectable, Logger } from '@nestjs/common' +import { AccountStatus, AccountType, NewAccount } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import axios from 'axios' +import FormData from 'form-data' +import * as _ from 'lodash' +import { v4 as uuidv4 } from 'uuid' +import { getCurrentTimestamp } from '../../../common' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { WebhookDto } from '../../../core/plat/pinterest/dto/pinterest.dto' +import { + AuthInfo, + CreateBoardBody, + CreatePinBody, + ILoginStatus, +} from '../../../libs/pinterest/common' +import { PinterestApiService } from '../../../libs/pinterest/pinterestApi.service' + +@Injectable() +export class PinterestService { + private readonly logger = new Logger(PinterestService.name) + private readonly redirectURL = config.pinterest.authBackHost + private readonly client_id = config.pinterest.id + + constructor( + private readonly pinterestApiService: PinterestApiService, + private readonly redisService: RedisService, + private readonly accountService: AccountService, + ) {} + + /** + * 创建board + * @param body + * @returns + */ + async createBoard(body: CreateBoardBody) { + this.logger.log(JSON.stringify(body)) + const accountId: string = _.get(body, 'accountId') || '' + _.unset(body, 'accountId') + const headers = await this.getAccessToken(accountId) + this.logger.log(JSON.stringify(body)) + return this.pinterestApiService.createBoard(body, headers) + } + + /** + * 获取board列表信息 + * @returns + */ + async getBoardList(accountId: string) { + const headers = await this.getAccessToken(accountId) + this.logger.log(JSON.stringify(headers)) + return this.pinterestApiService.getBoardList(headers) + } + + /** + * 获取board信息 + * @param id board id + * @param accountId + * @returns + */ + async getBoardById(id: string, accountId: string) { + const headers = await this.getAccessToken(accountId) + return this.pinterestApiService.getBoardById(id, headers) + } + + /** + * 删除board信息 + * @param id board id + * @param accountId + * @returns + */ + async delBoardById(id: string, accountId: string) { + const headers = await this.getAccessToken(accountId) + return this.pinterestApiService.delBoardById(id, headers) + } + + /** + * 创建pin + * @param body + * @returns + */ + async createPin(body: CreatePinBody) { + this.logger.log('--createPin--', JSON.stringify(body)) + const accountId: string = _.get(body, 'accountId') || '' + _.unset(body, 'accountId') + const headers = await this.getAccessToken(accountId) + if (_.isEmpty(headers)) + throw new AppException(100011, 'The authorization has expired.') + const data = await this.pinterestApiService.createPin(body, headers) + return { code: 0, data } + } + + /** + * 获取pin信息 + * @param id pin id + * @param accountId + * @returns + */ + async getPinById(id: string, accountId: string) { + const headers = await this.getAccessToken(accountId) + return this.pinterestApiService.getPinById(id, headers) + } + + /** + * 获取pin列表信息 + * @param accountId 签名 + * @returns + */ + async getPinList(accountId: string) { + const headers = await this.getAccessToken(accountId) + return this.pinterestApiService.getPinList(headers) + } + + /** + * 删除pin + * @param id pin id + * @param accountId + * @returns + */ + async delPinById(id: string, accountId: string) { + this.logger.log(`--delPinById--${id}, ${accountId}`) + const headers = await this.getAccessToken(accountId) + const data = await this.pinterestApiService.delPinById(id, headers) + return { code: 0, data } + } + + /** + * 获取授权地址 + * @param userId userId + * @returns + */ + async getAuth(userId: string, spaceId = '') { + const taskId = uuidv4().replace(/-/g, '') + const redisKeyByTaskId = this.getAuthDataCacheKey(taskId) + const scope + = 'scope=boards:read,boards:write,pins:write,pins:read,catalogs:read,catalogs:write,pins:write_secret,pins:read_secret,user_accounts:read' + const path = `response_type=code&redirect_uri=${this.redirectURL}&client_id=${this.client_id}&${scope}&state=${taskId}` + const uri = `https://www.pinterest.com/oauth/?${path}` + const tokenInfo = { taskId, userId, status: ILoginStatus.wait, spaceId } + await this.redisService.setJson(redisKeyByTaskId, tokenInfo, 60 * 5) + return { taskId, userId, status: ILoginStatus.wait, uri } + } + + async authWebhook(data: WebhookDto) { + const { code, state } = data + try { + const result = await this.pinterestApiService.authWebhook(code) + const { access_token, expires_in, refresh_token_expires_in } = result + const userInfo + = await this.pinterestApiService.getAccountInfo(access_token) + // 获取到token后第一时间创建account信息 + const redisKeyByTaskId = this.getAuthDataCacheKey(state) + const redisCache: any + = await this.redisService.getJson(redisKeyByTaskId) + const { userId } = redisCache + // 创建本平台的平台账号 + const newData = new NewAccount({ + userId, + type: AccountType.PINTEREST, + uid: userInfo.id, + avatar: userInfo.profile_image, + nickname: userInfo.username, + account: userInfo.id, + groupId: redisCache.spaceId, + status: AccountStatus.NORMAL, + }) + this.logger.log('NewAccount-data', JSON.stringify(newData)) + const accountInfo = await this.accountService.createAccount( + userId, + { + type: AccountType.PINTEREST, + uid: userInfo.id, + }, + newData, + ) + if (!accountInfo) { + return { + status: 0, + message: '添加账号失败', + } + } + const accessTokenExpiresIn = expires_in - 60 * 10 + getCurrentTimestamp() + const refreshTokenExpiresIn = refresh_token_expires_in + getCurrentTimestamp() + const tokenInfo = { + userInfo, + result, + taskId: state, + access_token, + code, + accountId: accountInfo.id, + expires_in: accessTokenExpiresIn, + refresh_token_expires_in: refreshTokenExpiresIn, + status: ILoginStatus.success, + userId, + } + await this.redisService.setJson( + this.getAccessTokenKey(accountInfo.id), + tokenInfo, + ) + // 更新任务信息 + const authDataCache = { taskId: state, status: ILoginStatus.success } + await this.redisService.setJson(redisKeyByTaskId, authDataCache, 5 * 60) + return { + status: 1, + message: '授权成功', + accountId: accountInfo.id, + } + } + catch (error) { + this.logger.error('----- pinterest Error authWebhook: ----', error.message) + return { + status: 0, + message: `获取授权失败: ${error.message}`, + } + } + } + + private getAuthDataCacheKey(taskId: string) { + return `channel:pinterest:authTask:${taskId}` + } + + private getAccessTokenKey(id: string) { + return `pinterest:accessToken:${id}` + } + + /** + * 查询授权状态 + * @param taskId taskId + * @returns + */ + async checkAuth(taskId: string) { + const redisKeyByTaskId = this.getAuthDataCacheKey(taskId) + const tokenInfo: AuthInfo | null + = await this.redisService.getJson(redisKeyByTaskId) + if (_.isEmpty(tokenInfo)) + return { taskId, status: ILoginStatus.expired } + const { status } = tokenInfo + return { taskId, status } + } + + async getAccessToken(accountId: string) { + this.logger.log(accountId) + const redisKey = this.getAccessTokenKey(accountId) + const tokenInfo: AuthInfo | null + = await this.redisService.getJson(redisKey) + this.logger.log(accountId, '授权信息', JSON.stringify(tokenInfo)) + if (_.isEmpty(tokenInfo)) + throw new AppException(100011, 'The authorization has expired.') + const { access_token, expires_in, refresh_token_expires_in } = tokenInfo + if (_.isEmpty(access_token) && _.isEmpty(refresh_token_expires_in)) + throw new AppException(100011, 'The authorization has expired.') + if (refresh_token_expires_in && refresh_token_expires_in < getCurrentTimestamp()) + throw new AppException(100011, 'The authorization has expired.') + if (expires_in && expires_in < getCurrentTimestamp()) { + throw new AppException(100011, 'The authorization has expired.') + } + return { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${access_token}`, + } + } + + async uploadVideo(videoUrl: string, accountId: string) { + // 获取视频的上传凭证 + const token = await this.getAccessToken(accountId) + // 获取视频文件 + const remoteResponse = await axios({ + method: 'get', + url: videoUrl, + responseType: 'stream', + }) + const path: any = videoUrl.split('/').pop() + remoteResponse.data.pipe(fs.createWriteStream(path)) + // 创建文件视频流 + const formData = new FormData() + const result: any = await this.pinterestApiService.getUploadHeaders(token) + const { upload_parameters: headers, upload_url, media_id } = result + // 添加文件流 + _.mapKeys(headers, (v, k) => { + formData.append(k, v) + }) + formData.append('file', fs.createReadStream(path)) + await this.pinterestApiService.uploadVideo(upload_url, formData) + fs.unlinkSync(path) + return { + data: { media_id }, + code: 0, + } + } + + async getUserStat(accountId: string) { + this.logger.log(accountId) + const redisKey = this.getAccessTokenKey(accountId) + const tokenInfo: AuthInfo | null + = await this.redisService.getJson(redisKey) + this.logger.log(accountId, '授权信息', JSON.stringify(tokenInfo)) + if (_.isEmpty(tokenInfo)) + throw new AppException(100011, 'The authorization has expired.') + const { access_token } = tokenInfo + if (_.isEmpty(access_token)) + throw new AppException(100011, 'The authorization has expired.') + return tokenInfo + } + + /** + * 获取当前授权账号的用户信息 + * 复用 getUserStat 校验与获取 token,避免重复代码 + */ + async getUserInfo(accountId: string) { + try { + const tokenInfo = await this.getUserStat(accountId) + const { access_token } = tokenInfo as any + const userInfo = await this.pinterestApiService.getAccountInfo(access_token) + return userInfo + } + catch (error) { + this.logger.error('----- pinterest Error getUserInfo: ----', (error as any)?.message) + throw new AppException(100011, 'The authorization has expired.') + } + } + + async getAccessTokenStatus(accountId: string) { + const redisKey = this.getAccessTokenKey(accountId) + const tokenInfo: AuthInfo | null + = await this.redisService.getJson(redisKey) + if (_.isEmpty(tokenInfo)) + return 0 + if (!tokenInfo.expires_in) + return 0 + return tokenInfo.expires_in > getCurrentTimestamp() ? 1 : 0 + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.controller.ts new file mode 100644 index 000000000..7c1a76457 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Logger, Param, Post } from '@nestjs/common' +import { AccountStatus } from '@yikart/aitoearn-server-client' +import { GenAuthURLDto } from './plat.dto' +import { PlatformService } from './plat.service' + +@Controller() +export class PlatformController { + private readonly logger = new Logger(PlatformController.name) + constructor(private readonly platformService: PlatformService) {} + + // @NatsMessagePattern('platform.user.accounts') + @Post('platform/user/accounts') + async getUserAccounts(@Body() data: { userId: string }) { + return await this.platformService.getUserAccounts(data.userId) + } + + @Post('platform/:userId/accounts') + async getUserAccountsByRestful(@Param('userId') userId: string) { + return await this.platformService.getUserAccounts(userId) + } + + // @NatsMessagePattern('platform.accounts.updateStatus') + @Post('platform/accounts/updateStatus') + async updateChannelStatus(@Body() data: { accountId: string, status: AccountStatus }) { + return await this.platformService.updateAccountStatus(data.accountId, data.status) + } + + // @NatsMessagePattern('platform.oauth.authorization.url') + @Post('platform/oauth/authorization/url') + async getAuthorizationUrl(@Body() data: GenAuthURLDto) { + this.logger.debug(`Getting authorization URL for platform: ${data.platform}`) + return await this.platformService.generateAuthorizationUrl(data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.dto.ts new file mode 100644 index 000000000..b8c58fdab --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.dto.ts @@ -0,0 +1,16 @@ +import { createZodDto } from '@yikart/common' +import z, { email } from 'zod' + +export const GenAuthURLSchema = z.object({ + platform: z.string().describe('平台类型'), + spaceId: z.string().describe('空间ID'), + type: z.string().optional().describe('授权类型,部分平台需要'), + prefix: z.string().optional().describe('前缀,部分平台需要'), + email: email().optional().describe('邮箱,部分平台需要'), + userId: z.string().describe('用户ID'), + scopes: z.array(z.string()).optional().describe('权限范围,部分平台需要'), +}) + +export class GenAuthURLDto extends createZodDto( + GenAuthURLSchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.module.ts new file mode 100644 index 000000000..53d7c92b0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common' +import { BilibiliModule } from './bilibili/bilibili.module' +import { KwaiModule } from './kwai/kwai.module' +import { MetaModule } from './meta/meta.module' +import { PinterestModule } from './pinterest/pinterest.module' +import { PlatformController } from './plat.controller' +import { PlatformService } from './plat.service' +import { TiktokModule } from './tiktok/tiktok.module' +import { TwitterModule } from './twitter/twitter.module' +import { WxPlatModule } from './wxPlat/wxPlat.module' +import { YoutubeModule } from './youtube/youtube.module' + +@Module({ + imports: [ + BilibiliModule, + KwaiModule, + MetaModule, + PinterestModule, + TiktokModule, + TwitterModule, + WxPlatModule, + YoutubeModule, + ], + controllers: [PlatformController], + providers: [PlatformService], + exports: [], +}) +export class PlatModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.service.ts new file mode 100644 index 000000000..8996d2e44 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/plat.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { RedisService } from '@yikart/redis' +import { isInstance } from 'class-validator' +import { AccountService } from '../account/account.service' +import { BilibiliService } from './bilibili/bilibili.service' +import { KwaiService } from './kwai/kwai.service' +import { MetaService } from './meta/meta.service' +import { PinterestService } from './pinterest/pinterest.service' +import { GenAuthURLDto } from './plat.dto' +import { TiktokService } from './tiktok/tiktok.service' +import { TwitterService } from './twitter/twitter.service' +import { YoutubeService } from './youtube/youtube.service' +// import { WxPlatService } from './wxPlat/wxPlat.service'; + +@Injectable() +export class PlatformService { + private readonly logger = new Logger(PlatformService.name) + private platformServiceMap: { [key in AccountType]?: any } = {} + constructor( + private readonly redisService: RedisService, + private readonly bilibiliService: BilibiliService, + private readonly kwaiService: KwaiService, + private readonly metaService: MetaService, + private readonly pinterestService: PinterestService, + private readonly tiktokService: TiktokService, + private readonly twitterService: TwitterService, + private readonly accountService: AccountService, + private readonly youtubeService: YoutubeService, + // private readonly wxPlatService: WxPlatService, + ) { + this.platformServiceMap[AccountType.BILIBILI] = this.bilibiliService + this.platformServiceMap[AccountType.KWAI] = this.kwaiService + this.platformServiceMap[AccountType.FACEBOOK] = this.metaService + this.platformServiceMap[AccountType.INSTAGRAM] = this.metaService + this.platformServiceMap[AccountType.THREADS] = this.metaService + this.platformServiceMap[AccountType.LINKEDIN] = this.metaService + this.platformServiceMap[AccountType.PINTEREST] = this.pinterestService + this.platformServiceMap[AccountType.TIKTOK] = this.tiktokService + this.platformServiceMap[AccountType.TWITTER] = this.twitterService + this.platformServiceMap[AccountType.YOUTUBE] = this.youtubeService + // this.platformServiceMap[AccountType.WxGzh] = this.wxPlatService; + } + + async getUserAccounts(userId: string) { + const accounts = await this.accountService.getUserAccountList(userId) + if (!accounts || accounts.length === 0) { + return [] + } + for (const account of accounts) { + const svc = this.platformServiceMap[account.type] + if (svc && svc.getAccessTokenStatus) { + if (isInstance(svc, MetaService)) { + account.status = await svc.getAccessTokenStatus(account._id.toString(), account.type) + } + else { + account.status = await svc.getAccessTokenStatus(account._id.toString()) + } + } + } + return accounts + } + + async updateAccountStatus(accountId: string, status: number) { + const res = await this.accountService.updateAccountStatus(accountId, status) + return res + } + + async generateAuthorizationUrl(data: GenAuthURLDto) { + const platform = data.platform as AccountType + const svc = this.platformServiceMap[platform] + if (!svc) { + throw new Error(`Unsupported platform: ${data.platform}`) + } + let resp: any = null + switch (platform) { + case AccountType.BILIBILI: + resp = await svc.createAuthTask({ + userId: data.userId, + type: data.type, + }) + break + case AccountType.WxGzh: + resp = await svc.createAuthTask({ + userId: data.userId, + type: data.type, + }) + break + case AccountType.KWAI: + resp = await svc.createAuthTask({ + userId: data.userId, + type: data.type, + }) + break + case AccountType.FACEBOOK: + case AccountType.INSTAGRAM: + case AccountType.THREADS: + case AccountType.LINKEDIN: + resp = await svc.generateAuthorizationUrl({ + userId: data.userId, + platform, + scopes: data.scopes, + }) + break + case AccountType.TIKTOK: + resp = await svc.getAuthUrl(data.userId, data.scopes) + break + case AccountType.TWITTER: + resp = await svc.generateAuthorizationUrl({ + userId: data.userId, + platform, + scopes: data.scopes, + }) + break + case AccountType.PINTEREST: + resp = await svc.getAuth({ + userId: data.userId, + }) + break + default: + throw new Error(`Unsupported platform: ${data.platform}`) + } + if (!resp || !resp.taskId) { + throw new Error(`Failed to generate authorization URL for platform: ${data.platform}`) + } + const spaceInfoCached = await this.redisService.setJson( + `platform:oauth:space:${resp.taskId}`, + data.spaceId, + 600, + ) + if (!spaceInfoCached) { + throw new Error(`Failed to generate authorization URL for platform: ${data.platform}`) + } + return resp + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/constants.ts new file mode 100644 index 000000000..34e8cb63a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/constants.ts @@ -0,0 +1,46 @@ +/* + * @Author: nevin + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: nevin + * @Description: TikTok模块常量定义 + */ + +/** + * TikTok Redis键管理类 + */ +export class TiktokRedisKeys { + private static readonly PREFIX = 'tiktok:' + + /** + * 获取授权任务键 + */ + static getAuthTaskKey(taskId: string): string { + return `${this.PREFIX}auth_task:${taskId}` + } + + /** + * 获取访问令牌键 + */ + static getAccessTokenKey(accountId: string): string { + return `${this.PREFIX}access_token:${accountId}` + } +} + +// 时间常量(秒) +export const TIKTOK_TIME_CONSTANTS = { + AUTH_TASK_EXPIRE: 5 * 60, // 认证任务过期时间:5分钟 + AUTH_TASK_EXTEND: 3 * 60, // 认证任务延长时间:3分钟 + TOKEN_EXPIRE_BUFFER: 10 * 60, // token过期缓冲时间:10分钟 + TOKEN_REFRESH_THRESHOLD: 15 * 60, // token刷新阈值:15分钟 +} as const + +// 默认权限范围 +export const TIKTOK_DEFAULT_SCOPES = [ + 'user.info.basic', + 'user.info.profile', + 'video.upload', + 'video.publish', + 'user.info.stats', + 'video.list', +] diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/dto/tiktok.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/dto/tiktok.dto.ts new file mode 100644 index 000000000..81a5a64c6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/dto/tiktok.dto.ts @@ -0,0 +1,245 @@ +import { Expose, Type } from 'class-transformer' +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from 'class-validator' +/* + * @Author: nevin + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: nevin + * @Description: TikTok DTO + */ +import { TiktokPostMode, TiktokPrivacyLevel, TiktokSourceType } from '../../../../libs/tiktok/tiktok.enum' + +// 发布信息DTO +export class PostInfoDto { + @IsString() + @IsOptional() + @Expose() + readonly title?: string + + @IsString() + @IsOptional() + @Expose() + readonly description?: string + + @IsEnum(TiktokPrivacyLevel) + @Expose() + readonly privacy_level: TiktokPrivacyLevel + + @IsBoolean() + @IsOptional() + @Expose() + readonly disable_comment?: boolean + + @IsBoolean() + @IsOptional() + @Expose() + readonly disable_duet?: boolean + + @IsBoolean() + @IsOptional() + @Expose() + readonly disable_stitch?: boolean + + @IsBoolean() + @IsOptional() + @Expose() + readonly auto_add_music?: boolean + + @IsBoolean() + @IsOptional() + @Expose() + readonly brand_content_toggle?: boolean + + @IsBoolean() + @IsOptional() + @Expose() + readonly brand_organic_toggle?: boolean + + @IsNumber() + @IsOptional() + @Expose() + readonly video_cover_timestamp_ms?: number +} + +// 视频源信息DTO - 文件上传方式 +export class VideoFileUploadSourceDto { + @IsEnum(TiktokSourceType) + @Expose() + readonly source: TiktokSourceType.FILE_UPLOAD + + @IsNumber() + @Expose() + readonly video_size: number + + @IsNumber() + @Expose() + readonly chunk_size: number + + @IsNumber() + @Expose() + readonly total_chunk_count: number +} + +// 视频源信息DTO - URL拉取方式 +export class VideoPullUrlSourceDto { + @IsEnum(TiktokSourceType) + @Expose() + readonly source: TiktokSourceType.PULL_FROM_URL + + @IsUrl() + @Expose() + readonly video_url: string +} + +// 照片源信息DTO +export class PhotoSourceInfoDto { + @IsEnum(TiktokSourceType) + @Expose() + readonly source: TiktokSourceType.PULL_FROM_URL + + @IsArray() + @IsUrl({}, { each: true }) + @Expose() + readonly photo_images: string[] + + @IsNumber() + @Expose() + readonly photo_cover_index: number +} + +export class AccountIdDto { + @IsString() + @Expose() + readonly accountId: string +} + +export class UserIdDto { + @IsString() + @Expose() + readonly userId: string +} + +export class GetAuthUrlDto extends UserIdDto { + @IsString() + @Expose() + readonly spaceId: string + + @IsArray() + @IsOptional() + @Expose() + readonly scopes?: string[] +} + +export class GetAuthInfoDto { + @IsString() + @Expose() + readonly taskId: string +} + +export class CreateAccountAndSetAccessTokenDto { + @IsString() + @Expose() + readonly code: string + + @IsString() + @Expose() + readonly state: string +} + +export class RefreshTokenDto extends AccountIdDto { + @IsString() + @Expose() + readonly refreshToken: string +} + +export class VideoPublishDto extends AccountIdDto { + @ValidateNested() + @Type(() => PostInfoDto) + @Expose() + readonly postInfo: PostInfoDto + + @ValidateNested() + @Type(() => Object, { + discriminator: { + property: 'source', + subTypes: [ + { value: VideoFileUploadSourceDto, name: TiktokSourceType.FILE_UPLOAD }, + { value: VideoPullUrlSourceDto, name: TiktokSourceType.PULL_FROM_URL }, + ], + }, + keepDiscriminatorProperty: true, + }) + @Expose() + readonly sourceInfo: VideoFileUploadSourceDto | VideoPullUrlSourceDto +} + +export class PhotoPublishDto extends AccountIdDto { + @IsEnum(TiktokPostMode) + @Expose() + readonly postMode: TiktokPostMode + + @ValidateNested() + @Type(() => PostInfoDto) + @Expose() + readonly postInfo: PostInfoDto + + @ValidateNested() + @Type(() => PhotoSourceInfoDto) + @Expose() + readonly sourceInfo: PhotoSourceInfoDto +} + +export class GetPublishStatusDto extends AccountIdDto { + @IsString() + @Expose() + readonly publishId: string +} + +export class UploadVideoFileDto { + @IsString() + @Expose() + readonly uploadUrl: string + + @IsString() + @Expose() + readonly videoBase64: string + + @IsString() + @IsOptional() + @Expose() + readonly contentType?: string +} + +export class UserInfoDto extends AccountIdDto { + @IsString() + @IsOptional() + @Expose() + readonly fields?: string +} + +export class ListUserVideosDto extends AccountIdDto { + @IsString() + @Expose() + readonly fields: string + + @IsNumber() + @IsOptional() + @Expose() + readonly cursor?: number + + @IsNumber() + @IsOptional() + @Expose() + readonly max_count?: number +} + +export class RevokeTokenDto extends AccountIdDto {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.controller.ts new file mode 100644 index 000000000..f79ce3cba --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.controller.ts @@ -0,0 +1,191 @@ +/* + * @Author: nevin + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: nevin + * @Description: TikTok Controller + */ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common' +import { + AccountIdDto, + CreateAccountAndSetAccessTokenDto, + GetAuthInfoDto, + GetAuthUrlDto, + GetPublishStatusDto, + ListUserVideosDto, + PhotoPublishDto, + RefreshTokenDto, + RevokeTokenDto, + UploadVideoFileDto, + UserInfoDto, + VideoPublishDto, +} from './dto/tiktok.dto' +import { TiktokService } from './tiktok.service' + +@Controller() +export class TiktokController { + constructor(private readonly tiktokService: TiktokService) {} + + // 获取页面的认证URL + // @NatsMessagePattern('plat.tiktok.authUrl') + @Post('plat/tiktok/authUrl') + async getAuthUrl(@Body() data: GetAuthUrlDto) { + return await this.tiktokService.getAuthUrl(data.userId, data.scopes, data.spaceId) + } + + // 查询认证信息 + // @NatsMessagePattern('plat.tiktok.getAuthInfo') + @Post('plat/tiktok/getAuthInfo') + async getAuthInfo(@Body() data: GetAuthInfoDto) { + return await this.tiktokService.getAuthInfo(data.taskId) + } + + @Get('auth/url') + async getOAuthAuthUri(@Query() query: GetAuthUrlDto) { + return await this.tiktokService.getAuthUrl(query.userId, query.scopes) + } + + // 获取AccessToken,并记录到用户,给平台回调用 + @Get('auth/back/:prefix/:taskId') + async getAccessToken( + @Param('prefix') prefix: string, + @Param('taskId') taskId: string, + @Query() + query: { + code: string + state: string + }, + ) { + return await this.tiktokService.createAccountAndSetAccessToken(taskId, { + code: query.code, + state: query.state, + }) + } + + @Get('auth/callback') + async postOAuth2CallbackByRestFul( + @Query() + query: { + code: string + state: string + }, + ) { + return await this.tiktokService.createAccountAndSetAccessToken(query.state, { + code: query.code, + state: query.state, + }) + } + + // 创建账号并设置授权Token + // @NatsMessagePattern('plat.tiktok.createAccountAndSetAccessToken') + @Post('plat/tiktok/createAccountAndSetAccessToken') + async createAccountAndSetAccessToken( + @Body() data: CreateAccountAndSetAccessTokenDto, + ) { + return await this.tiktokService.createAccountAndSetAccessToken( + data.state, + { + code: data.code, + state: data.state, + }, + ) + } + + // 刷新访问令牌 + // @NatsMessagePattern('plat.tiktok.refreshAccessToken') + @Post('plat/tiktok/refreshAccessToken') + async refreshAccessToken(@Body() data: RefreshTokenDto) { + return await this.tiktokService.refreshAccessToken( + data.accountId, + data.refreshToken, + ) + } + + // 撤销访问令牌 + // @NatsMessagePattern('plat.tiktok.revokeAccessToken') + @Post('plat/tiktok/revokeAccessToken') + async revokeAccessToken(@Body() data: RevokeTokenDto) { + return await this.tiktokService.revokeAccessToken(data.accountId) + } + + // 获取创作者信息 + // @NatsMessagePattern('plat.tiktok.getCreatorInfo') + @Post('plat/tiktok/getCreatorInfo') + async getCreatorInfo(@Body() data: AccountIdDto) { + return await this.tiktokService.getCreatorInfo(data.accountId) + } + + // 初始化视频发布 + // @NatsMessagePattern('plat.tiktok.initVideoPublish') + @Post('plat/tiktok/initVideoPublish') + async initVideoPublish(@Body() data: VideoPublishDto) { + return await this.tiktokService.initVideoPublish( + data.accountId, + data.postInfo, + data.sourceInfo, + ) + } + + // 初始化照片发布 + // @NatsMessagePattern('plat.tiktok.initPhotoPublish') + @Post('plat/tiktok/initPhotoPublish') + async initPhotoPublish(@Body() data: PhotoPublishDto) { + return await this.tiktokService.initPhotoPublish( + data.accountId, + data.postMode, + data.postInfo, + data.sourceInfo, + ) + } + + // 查询发布状态 + // @NatsMessagePattern('plat.tiktok.getPublishStatus') + @Post('plat/tiktok/getPublishStatus') + async getPublishStatus(@Body() data: GetPublishStatusDto) { + return await this.tiktokService.getPublishStatus( + data.accountId, + data.publishId, + ) + } + + // 上传视频文件 + // @NatsMessagePattern('plat.tiktok.uploadVideoFile') + @Post('plat/tiktok/uploadVideoFile') + async uploadVideoFile(@Body() data: UploadVideoFileDto) { + return await this.tiktokService.uploadVideoFile( + data.uploadUrl, + data.videoBase64, + data.contentType, + ) + } + + @Post('publish') + async publish( + @Body() data: { videoUrl: string, accountId: string }, + ) { + return await this.tiktokService.publishVideoViaURL(data.accountId, data.videoUrl) + } + + // @NatsMessagePattern('plat.tiktok.user.info') + @Post('plat/tiktok/user/info') + async getUserInfo(@Body() data: UserInfoDto) { + return await this.tiktokService.getUserInfo(data.accountId, data.fields) + } + + // @NatsMessagePattern('plat.tiktok.user.videos') + @Post('plat/tiktok/user/videos') + async listUserVideos(@Body() data: ListUserVideosDto) { + return await this.tiktokService.getUserVideos( + data.accountId, + data.fields, + data.cursor, + data.max_count, + ) + } + + // @NatsMessagePattern('plat.tiktok.accessTokenStatus') + @Post('plat/tiktok/accessTokenStatus') + async getAccessTokenStatus(@Body() data: AccountIdDto) { + return await this.tiktokService.getAccessTokenStatus(data.accountId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.module.ts new file mode 100644 index 000000000..ee9a2f6f0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.module.ts @@ -0,0 +1,26 @@ +/* + * @Author: nevin + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: nevin + * @Description: TikTok Module + */ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { OAuth2Crendential, OAuth2CrendentialSchema } from '../../../libs/database/schema/oauth2Crendential.schema' +import { TiktokModule as TiktokApiModule } from '../../../libs/tiktok/tiktok.module' +import { TiktokController } from './tiktok.controller' +import { TiktokService } from './tiktok.service' + +@Module({ + imports: [ + TiktokApiModule, + MongooseModule.forFeature([ + { name: OAuth2Crendential.name, schema: OAuth2CrendentialSchema }, + ]), + ], + controllers: [TiktokController], + providers: [TiktokService], + exports: [TiktokService], +}) +export class TiktokModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.service.ts new file mode 100644 index 000000000..1d92fc707 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/tiktok/tiktok.service.ts @@ -0,0 +1,504 @@ +/* eslint-disable style/indent */ +/* + * @Author: nevin + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: nevin + * @Description: TikTok业务服务 + */ +import { randomBytes } from 'node:crypto' +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountStatus, AccountType, NewAccount } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import { Model } from 'mongoose' +import { getCurrentTimestamp } from '../../../common' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { OAuth2Crendential } from '../../../libs/database/schema/oauth2Crendential.schema' +import { TiktokPostMode, TiktokPrivacyLevel, TiktokSourceType } from '../../../libs/tiktok/tiktok.enum' +import { + TiktokCreatorInfo, + TikTokListVideosParams, + TikTokListVideosResponse, + TiktokOAuthResponse, + TiktokPublishResponse, + TiktokPublishStatusResponse, + TiktokRevokeResponse, + TikTokUserInfoResponse, +} from '../../../libs/tiktok/tiktok.interfaces' +import { TiktokService as TiktokApiService } from '../../../libs/tiktok/tiktok.service' +import { TIKTOK_DEFAULT_SCOPES, TIKTOK_TIME_CONSTANTS, TiktokRedisKeys } from './constants' +import { + PhotoSourceInfoDto, + PostInfoDto, + VideoFileUploadSourceDto, + VideoPullUrlSourceDto, +} from './dto/tiktok.dto' + +export interface AuthTaskInfo { + state: string + userId: string + status: 0 | 1 + accountId?: string + spaceId?: string +} + +@Injectable() +export class TiktokService { + private readonly platform = AccountType.TIKTOK + private readonly defaultScopes: string[] + private readonly logger = new Logger(TiktokService.name) + + constructor( + private readonly redisService: RedisService, + private readonly tiktokApiService: TiktokApiService, + private readonly accountService: AccountService, + @InjectModel(OAuth2Crendential.name) + private OAuth2CrendentialModel: Model, + ) { + this.defaultScopes = config.tiktok.scopes.length > 0 + ? config.tiktok.scopes + : TIKTOK_DEFAULT_SCOPES + } + + /** + * 生成授权URL + */ + async getAuthUrl(userId: string, scopes?: string[], spaceId = '') { + const state = randomBytes(32).toString('hex') + const requestedScopes = scopes || this.defaultScopes + + const authUrl = this.tiktokApiService.generateAuthUrl(requestedScopes, state) + + const success = await this.redisService.setJson( + TiktokRedisKeys.getAuthTaskKey(state), + { state, status: 0, userId, spaceId }, + TIKTOK_TIME_CONSTANTS.AUTH_TASK_EXPIRE, + ) + + return success ? { url: authUrl, taskId: state, state } : null + } + + private async saveOAuthCredential(accountId: string, accessTokenInfo: TiktokOAuthResponse) { + accessTokenInfo.expires_in = getCurrentTimestamp() + accessTokenInfo.expires_in - TIKTOK_TIME_CONSTANTS.TOKEN_EXPIRE_BUFFER + accessTokenInfo.refresh_expires_in = getCurrentTimestamp() + accessTokenInfo.refresh_expires_in - TIKTOK_TIME_CONSTANTS.TOKEN_REFRESH_THRESHOLD + const cached = await this.redisService.setJson( + TiktokRedisKeys.getAccessTokenKey(accountId), + accessTokenInfo, + ) + const persistResult = await this.OAuth2CrendentialModel.updateOne({ + accountId, + platform: this.platform, + }, { + accessToken: accessTokenInfo.access_token, + refreshToken: accessTokenInfo.refresh_token, + accessTokenExpiresAt: accessTokenInfo.expires_in, + refreshTokenExpiresAt: accessTokenInfo.refresh_expires_in, + }, { + upsert: true, + }) + const saved = cached && (persistResult.modifiedCount > 0 || persistResult.upsertedCount > 0) + return saved + } + + private async getOAuth2Credential(accountId: string): Promise { + let credential = await this.redisService.getJson( + TiktokRedisKeys.getAccessTokenKey(accountId), + ) + if (!credential) { + const oauth2Credential = await this.OAuth2CrendentialModel.findOne({ + accountId, + platform: this.platform, + }) + if (!oauth2Credential) { + return null + } + credential = { + access_token: oauth2Credential.accessToken, + refresh_token: oauth2Credential.refreshToken, + expires_in: oauth2Credential.accessTokenExpiresAt, + refresh_expires_in: oauth2Credential.refreshTokenExpiresAt || 0, + scope: '', + token_type: '', + open_id: '', + } + } + return credential + } + + /** + * 获取授权任务信息 + */ + async getAuthInfo(taskId: string) { + const result = await this.redisService.getJson( + TiktokRedisKeys.getAuthTaskKey(taskId), + ) + if (!result) { + this.logger.warn(`OAuth2 task not found for taskId: ${taskId}`) + return { + taskId, + status: 0, + } + } + return result + } + + /** + * 创建账号并设置访问令牌 + */ + async createAccountAndSetAccessToken( + taskId: string, + authData: { code: string, state: string }, + ) { + const { code } = authData + + const authTaskInfo = await this.redisService.getJson( + TiktokRedisKeys.getAuthTaskKey(taskId), + ) + if (!authTaskInfo) { + return { + status: 0, + message: '授权任务不存在或已过期', + } + } + + // 延长授权任务时间 + void this.redisService.expire( + TiktokRedisKeys.getAuthTaskKey(taskId), + TIKTOK_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + + // 获取访问令牌 + const accessTokenInfo = await this.tiktokApiService.getAccessToken(code) + if (!accessTokenInfo) { + return { + status: 0, + message: '获取访问令牌失败', + } + } + this.logger.log(`获取访问令牌成功: ${JSON.stringify(accessTokenInfo)}`) + // 获取TikTok用户信息 + const userInfo = await this.fetchUserInfo( + accessTokenInfo.access_token, + ) + + this.logger.log(`TikTok user info: ${JSON.stringify(userInfo)}`) + // 创建账号数据 + const newAccountData = new NewAccount({ + userId: authTaskInfo.userId, + type: AccountType.TIKTOK, + uid: accessTokenInfo.open_id, + account: userInfo.data.user.username, + avatar: userInfo.data.user.avatar_url, + nickname: userInfo.data.user.display_name || userInfo.data.user.username, + groupId: authTaskInfo.spaceId, + status: AccountStatus.NORMAL, + }) + + const accountInfo = await this.accountService.createAccount( + authTaskInfo.userId, + { + type: AccountType.TIKTOK, + uid: accessTokenInfo.open_id, + }, + newAccountData, + ) + + if (!accountInfo) { + return { + status: 0, + message: '创建账号失败', + } + } + + // 保存访问令牌 + const tokenSaved = await this.saveOAuthCredential( + accountInfo.id, + accessTokenInfo, + ) + if (!tokenSaved) { + return { + status: 0, + message: '保存访问令牌失败', + } + } + // 更新任务状态 + const taskUpdated = await this.updateAuthTaskStatus( + taskId, + authTaskInfo, + accountInfo.id, + ) + + return taskUpdated + ? { + status: 1, + message: '授权成功', + accountId: accountInfo.id, + } + : { + status: 0, + message: '更新任务状态失败', + } + } + + /** + * 获取有效的访问令牌 + */ + private async getValidAccessToken(accountId: string): Promise { + let tokenInfo = await this.getOAuth2Credential(accountId) + if (!tokenInfo) { + throw new AppException(4001, '账号未授权') + } + + // 检查是否需要刷新令牌 + const currentTime = getCurrentTimestamp() + if ( + currentTime >= tokenInfo.expires_in + ) { + const refreshedToken = await this.performTokenRefresh( + accountId, + tokenInfo.refresh_token, + ) + if (refreshedToken) { + tokenInfo = refreshedToken + } + } + return tokenInfo.access_token + } + + /** + * 执行令牌刷新 + */ + private async performTokenRefresh( + accountId: string, + refreshToken: string, + ): Promise { + const newTokenInfo + = await this.tiktokApiService.refreshAccessToken(refreshToken) + if (!newTokenInfo) + return null + + const tokenSaved = await this.saveOAuthCredential(accountId, newTokenInfo) + return tokenSaved ? newTokenInfo : null + } + + /** + * 刷新访问令牌 + */ + async refreshAccessToken( + accountId: string, + refreshToken: string, + ): Promise { + return this.performTokenRefresh(accountId, refreshToken) + } + + /** + * 撤销访问令牌 + */ + async revokeAccessToken(accountId: string): Promise { + const accessToken = await this.getValidAccessToken(accountId) + const result = await this.tiktokApiService.revokeAccessToken(accessToken) + + await this.redisService.del(TiktokRedisKeys.getAccessTokenKey(accountId)) + + return result + } + + /** + * 获取创作者信息 + */ + async getCreatorInfo(accountId: string): Promise { + const accessToken = await this.getValidAccessToken(accountId) + return await this.tiktokApiService.getCreatorInfo(accessToken) + } + + async getUserInfo(accountId: string, fields?: string): Promise { + const accessToken = await this.getValidAccessToken(accountId) + return await this.tiktokApiService.getUserInfo(accessToken, fields) + } + + /** + * 初始化视频发布 + */ + async initVideoPublish( + accountId: string, + postInfo: PostInfoDto, + sourceInfo: VideoFileUploadSourceDto | VideoPullUrlSourceDto, + ): Promise { + const accessToken = await this.getValidAccessToken(accountId) + return await this.tiktokApiService.initVideoPublish(accessToken, { + post_info: postInfo, + source_info: sourceInfo, + }) + } + + /** + * 初始化照片发布 + */ + async initPhotoPublish( + accountId: string, + postMode: TiktokPostMode, + postInfo: PostInfoDto, + sourceInfo: PhotoSourceInfoDto, + ): Promise { + const accessToken = await this.getValidAccessToken(accountId) + return await this.tiktokApiService.initPhotoPublish(accessToken, { + media_type: 'PHOTO', + post_mode: postMode, + post_info: postInfo, + source_info: sourceInfo, + }) + } + + /** + * 查询发布状态 + */ + async getPublishStatus( + accountId: string, + publishId: string, + ): Promise { + const accessToken = await this.getValidAccessToken(accountId) + return await this.tiktokApiService.getPublishStatus(accessToken, publishId) + } + + /** + * 上传视频文件 + */ + async uploadVideoFile( + uploadUrl: string, + videoBase64: string, + contentType?: string, + ): Promise { + const videoBuffer = Buffer.from(videoBase64, 'base64') + await this.tiktokApiService.uploadVideoFile( + uploadUrl, + videoBuffer, + contentType, + ) + } + + async chunkedUploadVideoFile( + uploadUrl: string, + videoBuffer: Buffer, + range: [number, number], + fileSize: number, + contentType: string, + ): Promise { + await this.tiktokApiService.chunkedUploadVideoFile( + uploadUrl, + videoBuffer, + range, + fileSize, + contentType, + ) + } + + private async fetchUserInfo( + accessToken: string, + ): Promise { + return await this.tiktokApiService.getUserInfo(accessToken) + } + + /** + * 通过令牌获取创作者信息 + */ + private async fetchCreatorInfo( + accessToken: string, + ): Promise { + return await this.tiktokApiService.getCreatorInfo(accessToken) + } + + /** + * 保存访问令牌 + */ + private async saveAccessToken( + accountId: string, + tokenInfo: TiktokOAuthResponse, + ): Promise { + const now = getCurrentTimestamp() + tokenInfo.expires_in = now + tokenInfo.expires_in - TIKTOK_TIME_CONSTANTS.TOKEN_EXPIRE_BUFFER + tokenInfo.refresh_expires_in = now + tokenInfo.refresh_expires_in - TIKTOK_TIME_CONSTANTS.TOKEN_REFRESH_THRESHOLD + return await this.redisService.setJson( + TiktokRedisKeys.getAccessTokenKey(accountId), + tokenInfo, + ) + } + + /** + * 更新授权任务状态 + */ + private async updateAuthTaskStatus( + taskId: string, + authTaskInfo: AuthTaskInfo, + accountId: string, + ): Promise { + authTaskInfo.status = 1 + authTaskInfo.accountId = accountId + + return await this.redisService.setJson( + TiktokRedisKeys.getAuthTaskKey(taskId), + authTaskInfo, + TIKTOK_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + } + + async publishVideoViaURL( + accountId: string, + videoUrl: string, + ): Promise { + this.logger.log(`开始发布视频,accountId: ${accountId}, videoUrl: ${videoUrl}`) + const accessToken = await this.getValidAccessToken(accountId) + const privacyLevel = TiktokPrivacyLevel.SELF_ONLY + const postInfo: PostInfoDto = { + title: 'PULL FROM URL #NFG', + privacy_level: privacyLevel, + brand_content_toggle: false, + brand_organic_toggle: false, + } + + const sourceInfo: VideoPullUrlSourceDto = { + source: TiktokSourceType.PULL_FROM_URL, + video_url: videoUrl, + } + + const publishRes = await this.tiktokApiService.initVideoPublish( + accessToken, + { + post_info: postInfo, + source_info: sourceInfo, + }, + ) + this.logger.log(`视频发布结果: ${JSON.stringify(publishRes)}`) + if (!publishRes || !publishRes.publish_id) { + throw new Error('publish video failed') + } + return publishRes.publish_id + } + + async getUserVideos( + accountId: string, + fields: string, + cursor?: number, + max_count?: number, + ): Promise { + const accessToken = await this.getValidAccessToken(accountId) + const params: TikTokListVideosParams = { + fields, + } + if (cursor) + params.cursor = cursor + if (max_count) + params.max_count = max_count + return await this.tiktokApiService.getUserVideos(accessToken, params) + } + + async getAccessTokenStatus(accountId: string): Promise { + const tokenInfo = await this.getOAuth2Credential(accountId) + if (!tokenInfo) { + return 0 + } + return tokenInfo.expires_in > getCurrentTimestamp() ? 1 : 0 + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/constants.ts new file mode 100644 index 000000000..e2bb06c21 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/constants.ts @@ -0,0 +1,20 @@ +// copy from tiktok constants, keep the same thresholds +export class TwitterRedisKeys { + private static readonly PREFIX = 'twitter:' + + static getAuthTaskKey(state: string): string { + return `${this.PREFIX}auth_task:${state}` + } + + static getAccessTokenKey(accountId: string): string { + return `${this.PREFIX}access_token:${accountId}` + } +} + +// thresholds for twitter oAuth +export const TWITTER_TIME_CONSTANTS = { + AUTH_TASK_EXPIRE: 5 * 60, // for oauth task + AUTH_TASK_EXTEND: 3 * 60, // extend oauth task + TOKEN_REFRESH_MARGIN: 10 * 60, // margin for token refresh + TOKEN_REFRESH_THRESHOLD: 15 * 60, // threshold for token refresh +} as const diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/dto/twitter.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/dto/twitter.dto.ts new file mode 100644 index 000000000..41ae60b44 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/dto/twitter.dto.ts @@ -0,0 +1,52 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +const AccountIdSchema = z.object({ + accountId: z.string(), +}) +export class AccountIdDto extends createZodDto(AccountIdSchema) { } + +const UserIdSchema = z.object({ + userId: z.string(), +}) + +export class UserIdDto extends createZodDto(UserIdSchema) { } + +const GetAuthUrlSchema = z.object({ + userId: z.string(), + spaceId: z.string(), + scopes: z.array(z.string()).optional(), +}) +export class GetAuthUrlDto extends createZodDto(GetAuthUrlSchema) { } + +const GetAuthInfoSchema = z.object({ + taskId: z.string(), +}) +export class GetAuthInfoDto extends createZodDto(GetAuthInfoSchema) { } + +const CreateAccountAndSetAccessTokenSchema = z.object({ + code: z.string(), + state: z.string(), +}) + +export class CreateAccountAndSetAccessTokenDto extends createZodDto(CreateAccountAndSetAccessTokenSchema) {} + +const RefreshTokenSchema = z.object({ + accountId: z.string(), + refreshToken: z.string(), +}) +export class RefreshTokenDto extends createZodDto(RefreshTokenSchema) {} + +const UserTimelineSchema = z.object({ + accountId: z.string(), + userId: z.string(), + sinceId: z.string().optional(), + untilId: z.string().optional(), + maxResults: z.string().optional(), + paginationToken: z.string().optional(), + exclude: z.array(z.enum(['retweets', 'replies'])).optional(), + startTime: z.string().optional(), + endTime: z.string().optional(), +}) + +export class UserTimelineDto extends createZodDto(UserTimelineSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.controller.ts new file mode 100644 index 000000000..54b808355 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.controller.ts @@ -0,0 +1,149 @@ +import { Body, Controller, Delete, Get, Logger, Param, Post, Query } from '@nestjs/common' +import { + CreateAccountAndSetAccessTokenDto, + GetAuthInfoDto, + GetAuthUrlDto, + UserTimelineDto, +} from './dto/twitter.dto' +import { TwitterService } from './twitter.service' + +@Controller() +export class TwitterController { + private readonly logger = new Logger(TwitterController.name) + constructor(private readonly twitterService: TwitterService) { } + + // generate authorization URL + // @NatsMessagePattern('plat.twitter.authUrl') + @Post('plat/twitter/authUrl') + async generateAuthorizeURL(@Body() data: GetAuthUrlDto) { + return await this.twitterService.generateAuthorizeURL( + data.userId, + data.scopes, + data.spaceId, + ) + } + + // check oauth task status + // @NatsMessagePattern('plat.twitter.getAuthInfo') + @Post('plat/twitter/getAuthInfo') + async getOAuth2TaskInfo(@Body() data: GetAuthInfoDto) { + const result = await this.twitterService.getOAuth2TaskInfo(data.taskId) + if (!result) { + this.logger.warn(`OAuth2 task not found for state: ${data.taskId}`) + return { + state: data.taskId, + status: 0, + } + } + return result + } + + // restFul API for get oauth authorize URL + @Get('oauth2/authorize_url') + async getOAuthAuthUri( + @Query() + query: { + userId: string + scopes?: string[] + }, + ) { + return await this.twitterService.generateAuthorizeURL( + query.userId, + query.scopes, + ) + } + + // restFul API for post oauth callback + @Get('auth/callback') + async postOAuth2CallbackByRestFul( + @Query() + query: { + code: string + state: string + }, + ) { + const result = await this.twitterService.postOAuth2Callback(query.state, { + code: query.code, + state: query.state, + }) + this.logger.error('postOAuth2CallbackByRestFul result:', result) + return result + } + + @Post('publish') + async publishPost( + @Body() data: { imgUrlList: string[], videoUrl: string, desc: string, accountId: string }, + ) { + return await this.twitterService.publishPost(data.accountId, data.imgUrlList, data.videoUrl, data.desc) + } + + // NATS message pattern for post oauth callback + // get access token and create account + // @NatsMessagePattern('plat.twitter.createAccountAndSetAccessToken') + @Post('plat/twitter/createAccountAndSetAccessToken') + async postOAuth2Callback(@Body() data: CreateAccountAndSetAccessTokenDto) { + return await this.twitterService.postOAuth2Callback(data.state, { + code: data.code, + state: data.state, + }) + } + + // @NatsMessagePattern('plat.twitter.user.info') + @Post('plat/twitter/user/info') + async getUserInfo(@Body() data: { accountId: string }) { + return await this.twitterService.getUserInfo(data.accountId) + } + + // @NatsMessagePattern('plat.twitter.timeline') + @Post('plat/twitter/timeline') + async getUserTimeline(@Body() data: UserTimelineDto) { + return await this.twitterService.getUserTimeline(data.accountId, data.userId, data) + } + + // @NatsMessagePattern('plat.twitter.user.posts') + @Post('plat/twitter/user/posts') + async getUserPosts(@Body() data: UserTimelineDto) { + return await this.twitterService.getUserPosts(data.accountId, data.userId, data) + } + + // @NatsMessagePattern('plat.twitter.tweet.detail') + @Post('plat/twitter/tweet/detail') + async getTweetDetail(@Body() data: { userId, tweetId: string }) { + return await this.twitterService.getTweetDetail(data.userId, data.tweetId) + } + + // @NatsMessagePattern('plat.twitter.tweet.repost') + @Post('plat/twitter/tweet/repost') + async repostTweet(@Body() data: { userId, tweetId: string }) { + return await this.twitterService.repost(data.userId, data.tweetId) + } + + // @NatsMessagePattern('plat.twitter.tweet.like') + @Post('plat/twitter/tweet/like') + async likeTweet(@Body() data: { userId, tweetId: string }) { + return await this.twitterService.likePost(data.userId, data.tweetId) + } + + // @NatsMessagePattern('plat.twitter.tweet.unlike') + @Post('plat/twitter/tweet/unlike') + async unlikeTweet(@Body() data: { userId, tweetId: string }) { + return await this.twitterService.unlikePost(data.userId, data.tweetId) + } + + @Delete(':accountId/tweets/:tweetId') + async deleteTweet(@Param('tweetId') tweetId: string, @Param('accountId') accountId: string) { + return await this.twitterService.deletePost(accountId, tweetId) + } + + // @NatsMessagePattern('plat.twitter.tweet.reply') + @Post('plat/twitter/tweet/reply') + async replyTweet(@Body() data: { userId, tweetId, text: string }) { + return await this.twitterService.replyPost(data.userId, data.tweetId, data.text) + } + + // @NatsMessagePattern('plat.twitter.tweet.quote') + @Post('plat/twitter/tweet/quote') + async quoteTweet(@Body() data: { userId, tweetId, text: string }) { + return await this.twitterService.quotePost(data.userId, data.tweetId, data.text) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.interfaces.ts new file mode 100644 index 000000000..0a3ef8f04 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.interfaces.ts @@ -0,0 +1,9 @@ +export interface TwitterOAuthTaskInfo { + state: string + codeVerifier: string + userId: string + status: 0 | 1 + accountId?: string + spaceId?: string + taskId: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.module.ts new file mode 100644 index 000000000..861677103 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { OAuth2Crendential, OAuth2CrendentialSchema } from '../../../libs/database/schema/oauth2Crendential.schema' +import { TwitterModule as TwitterApiModule } from '../../../libs/twitter/twitter.module' +import { TwitterController } from './twitter.controller' +import { TwitterService } from './twitter.service' + +@Module({ + imports: [ + TwitterApiModule, + MongooseModule.forFeature([ + { name: OAuth2Crendential.name, schema: OAuth2CrendentialSchema }, + ]), + ], + controllers: [TwitterController], + providers: [TwitterService], + exports: [TwitterService], +}) +export class TwitterModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.service.ts new file mode 100644 index 000000000..954b2d805 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/twitter/twitter.service.ts @@ -0,0 +1,722 @@ +import { createHash, randomBytes } from 'node:crypto' +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountStatus, AccountType, NewAccount } from '@yikart/aitoearn-server-client' +import { RedisService } from '@yikart/redis' +import { Model } from 'mongoose' +import { v4 as uuidV4 } from 'uuid' +import { chunkedDownloadFile, fileUrlToBlob, getCurrentTimestamp, getFileSizeFromUrl, getFileTypeFromUrl } from '../../../common' +import { AccountService } from '../../../core/account/account.service' +import { OAuth2Crendential } from '../../../libs/database/schema/oauth2Crendential.schema' +import { XMediaCategory, XMediaType } from '../../../libs/twitter/twitter.enum' +import { TwitterOAuthCredential, XChunkedMediaUploadRequest, XCreatePostRequest, XCreatePostResponse, XDeletePostResponse, XLikePostResponse, XMediaUploadInitRequest, XMediaUploadResponse, XPostDetailResponse, XRePostResponse, XUserTimelineRequest } from '../../../libs/twitter/twitter.interfaces' +import { TwitterService as TwitterApiService } from '../../../libs/twitter/twitter.service' +import { TWITTER_TIME_CONSTANTS, TwitterRedisKeys } from './constants' +import { UserTimelineDto } from './dto/twitter.dto' +import { TwitterOAuthTaskInfo } from './twitter.interfaces' + +@Injectable() +export class TwitterService { + private readonly platform = AccountType.TWITTER + private readonly redisService: RedisService + private readonly twitterApiService: TwitterApiService + private readonly accountService: AccountService + private readonly logger = new Logger(TwitterService.name) + private readonly defaultScopes = [ + 'tweet.read', // All the Tweets you can view, including Tweets from protected accounts. + 'tweet.write', // Tweet and Retweet for you. + 'tweet.moderate.write', // Hide and unhide replies to your Tweets. + 'users.email', // Email from an authenticated user. + 'users.read', // Any account you can view, including protected accounts. + 'follows.read', // People who follow you and people who you follow. + 'follows.write', // Follow and unfollow people for you. + 'offline.access', // Stay connected to your account until you revoke access. + 'space.read', // All the Spaces you can view. + 'mute.read', // Accounts you’ve muted. + 'mute.write', // Mute and unmute accounts for you. + 'like.read', // Tweets you’ve liked and likes you can view. + 'like.write', // Like and un-like Tweets for you. + 'list.read', // Lists, list members, and list followers of lists you’ve created or are a member of, including private lists. + 'list.write', // Create and manage Lists for you. + 'block.read', // Accounts you’ve blocked. + 'block.write', // Block and unblock accounts for you. + 'bookmark.read', // Get Bookmarked Tweets from an authenticated user. + 'bookmark.write', // Bookmark and remove Bookmarks from Tweets. + 'media.write', // Upload media. + ] + + constructor( + redisService: RedisService, + twitterApiService: TwitterApiService, + accountService: AccountService, + @InjectModel(OAuth2Crendential.name) + private OAuth2CrendentialModel: Model, + ) { + this.redisService = redisService + this.twitterApiService = twitterApiService + this.accountService = accountService + } + + private async saveOAuthCredential(accountId: string, accessTokenInfo: TwitterOAuthCredential) { + accessTokenInfo.expires_in = accessTokenInfo.expires_in + getCurrentTimestamp() - TWITTER_TIME_CONSTANTS.TOKEN_REFRESH_MARGIN + const cached = await this.redisService.setJson( + TwitterRedisKeys.getAccessTokenKey(accountId), + accessTokenInfo, + ) + const persistResult = await this.OAuth2CrendentialModel.updateOne({ + accountId, + platform: this.platform, + }, { + accessToken: accessTokenInfo.access_token, + refreshToken: accessTokenInfo.refresh_token, + accessTokenExpiresAt: accessTokenInfo.expires_in, + }, { + upsert: true, + }) + const saved = cached && (persistResult.modifiedCount > 0 || persistResult.upsertedCount > 0) + return saved + } + + private async getOAuth2Credential(accountId: string): Promise { + let credential = await this.redisService.getJson( + TwitterRedisKeys.getAccessTokenKey(accountId), + ) + if (!credential) { + const oauth2Credential = await this.OAuth2CrendentialModel.findOne({ + accountId, + platform: this.platform, + }) + if (!oauth2Credential) { + return null + } + credential = { + access_token: oauth2Credential.accessToken, + refresh_token: oauth2Credential.refreshToken, + expires_in: oauth2Credential.accessTokenExpiresAt, + } + } + return credential + } + + private async authorize( + accountId: string, + ): Promise { + const credential = await this.getOAuth2Credential(accountId) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const now = getCurrentTimestamp() + if (now >= credential.expires_in) { + this.logger.debug( + `Access token for accountId: ${accountId} is expired, refreshing...`, + ) + const refreshedToken = await this.refreshOAuthCredential( + credential.refresh_token, + ) + if (!refreshedToken) { + this.logger.error( + `Failed to refresh access token for accountId: ${accountId}`, + ) + return null + } + credential.access_token = refreshedToken.access_token + credential.refresh_token = refreshedToken.refresh_token + credential.expires_in = refreshedToken.expires_in + const saved = await this.saveOAuthCredential(accountId, credential) + if (!saved) { + this.logger.error( + `Failed to save refreshed access token for accountId: ${accountId}`, + ) + return null + } + return credential + } + return credential + } + + private async refreshOAuthCredential(refresh_token: string) { + try { + const credential + = await this.twitterApiService.refreshOAuthCredential(refresh_token) + if (!credential) { + this.logger.error(`Failed to refresh access token`) + return null + } + return credential + } + catch (error) { + if (error.response) { + this.logger.error(`Error response: ${JSON.stringify(error.response.data)}`) + throw new Error(error.response.data.error || 'Failed to refresh access token') + } + this.logger.error(`Error: ${error.message}`) + throw new Error('Failed to refresh access token') + } + } + + async generateAuthorizeURL(userId: string, scopes?: string[], spaceId = '') { + const taskId = uuidV4() + const codeVerifier = randomBytes(64).toString('hex') + const codeChallenge = createHash('sha256') + .update(codeVerifier) + .digest('base64url') + const state = randomBytes(32).toString('hex') + const success = await this.redisService.setJson( + TwitterRedisKeys.getAuthTaskKey(state), + { state, status: 0, userId, codeVerifier, taskId, spaceId }, + TWITTER_TIME_CONSTANTS.AUTH_TASK_EXPIRE, + ) + scopes = scopes || this.defaultScopes + const authorizeURL = this.twitterApiService.generateAuthorizeURL( + scopes, + state, + codeChallenge, + ) + return success ? { url: authorizeURL, taskId: state, state } : null + } + + async getOAuth2TaskInfo(state: string) { + return await this.redisService.getJson( + TwitterRedisKeys.getAuthTaskKey(state), + ) + } + + async postOAuth2Callback( + state: string, + authData: { code: string, state: string }, + ) { + const { code } = authData + + const authTaskInfo = await this.getOAuth2TaskInfo(state) + if (!authTaskInfo) { + this.logger.error(`OAuth task not found for state: ${state}`) + return { + status: 0, + message: '授权任务不存在或已过期', + } + } + + // 延长授权任务时间 + void this.redisService.expire( + TwitterRedisKeys.getAuthTaskKey(state), + TWITTER_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + + const credential = await this.twitterApiService.getOAuthCredential( + code, + authTaskInfo.codeVerifier, + ) + if (!credential) { + this.logger.error(`Failed to get access token for state: ${state}`) + return { + status: 0, + message: '获取访问令牌失败', + } + } + + // fetch twitter user profile + const userProfile = await this.twitterApiService.getUserInfo( + credential.access_token, + ) + this.logger.log(userProfile) + + // 创建账号数据 + const newAccountData = new NewAccount({ + userId: authTaskInfo.userId, + type: AccountType.TWITTER, + uid: userProfile.id, + account: userProfile.username, + avatar: userProfile.profile_image_url, + nickname: userProfile.name, + lastStatsTime: new Date(), + loginTime: new Date(), + groupId: authTaskInfo.spaceId, + status: AccountStatus.NORMAL, + }) + + const accountInfo = await this.accountService.createAccount( + authTaskInfo.userId, + { + type: AccountType.TWITTER, + uid: userProfile.id, + }, + newAccountData, + ) + if (!accountInfo) { + this.logger.error( + `Failed to create account for userId: ${authTaskInfo.userId}, twitterId: ${userProfile.id}`, + ) + return { + status: 0, + message: '创建账号失败', + } + } + const tokenSaved = await this.saveOAuthCredential( + accountInfo.id, + credential, + ) + if (!tokenSaved) { + this.logger.error( + `Failed to save access token for accountId: ${accountInfo.id}`, + ) + return { + status: 0, + message: '保存访问令牌失败', + } + } + const taskUpdated = await this.updateAuthTaskStatus( + state, + authTaskInfo, + accountInfo.id, + ) + + if (!taskUpdated) { + this.logger.error( + `Failed to update auth task status for state: ${state}, accountId: ${accountInfo.id}`, + ) + return { + status: 0, + message: '更新任务状态失败', + } + } + return { + status: 1, + message: '授权成功', + accountId: accountInfo.id, + } + } + + private async updateAuthTaskStatus( + state: string, + authTaskInfo: TwitterOAuthTaskInfo, + accountId: string, + ): Promise { + authTaskInfo.status = 1 + authTaskInfo.accountId = accountId + + return await this.redisService.setJson( + TwitterRedisKeys.getAuthTaskKey(state), + authTaskInfo, + TWITTER_TIME_CONSTANTS.AUTH_TASK_EXTEND, + ) + } + + private async revokeOAuthCredential(accountId: string): Promise { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return false + } + const result = await this.twitterApiService.revokeOAuthCredential( + credential.access_token, + ) + if (result.revoked) { + await this.redisService.del( + TwitterRedisKeys.getAccessTokenKey(accountId), + ) + return true + } + return false + } + + public async getUserInfo(accountId: string) { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + return await this.twitterApiService.getUserInfo(credential.access_token) + } + + public async followUser(userId: string, targetXUserId: string) { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return false + } + return await this.twitterApiService.followUser( + credential.access_token, + targetXUserId, + ) + } + + public async initMediaUpload(userId: string, req: XMediaUploadInitRequest) { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.initMediaUpload(credential.access_token, req) + } + + public async chunkedMediaUploadRequest(userId: string, req: XChunkedMediaUploadRequest) { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.chunkedMediaUploadRequest(credential.access_token, req) + } + + public async finalizeMediaUpload(userId: string, mediaId: string) { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.finalizeMediaUpload(credential.access_token, mediaId) + } + + public async createPost(userId: string, post: XCreatePostRequest) { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.createPost(credential.access_token, post) + } + + public async deletePost(userId: string, tweetId: string): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.deletePost( + credential.access_token, + tweetId, + ) + } + + public async getMediaUploadStatus( + userId: string, + mediaId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.getMediaStatus( + credential.access_token, + mediaId, + ) + } + + async publishPost( + accountId: string, + imgUrlList: string[] | null, + videoUrl: string | null, + text: string, + ) { + this.logger.log(`dopub, ${accountId}, ${videoUrl}, ${text}`) + const twitterMediaIDs: string[] = [] + if (imgUrlList) { + for (const imgUrl of imgUrlList) { + const imgBlob = await fileUrlToBlob(imgUrl) + if (!imgBlob) { + this.logger.error('图片下载失败') + return null + } + this.logger.log('imgBlob', imgBlob.blob.size) + const fileName = getFileTypeFromUrl(imgUrl) + const ext = fileName.split('.').pop()?.toLowerCase() + const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}` + const initUploadReq: XMediaUploadInitRequest = { + media_type: mimeType as XMediaType, + total_bytes: imgBlob.blob.size, + media_category: XMediaCategory.TWEET_IMAGE, + shared: false, + } + this.logger.log('initMediaUpload', initUploadReq) + const initUploadRes = await this.initMediaUpload( + accountId, + initUploadReq, + ) + this.logger.log(initUploadRes) + if (!initUploadRes || !initUploadRes.data.id) { + this.logger.error('图片初始化上传失败') + return null + } + const uploadReq: XChunkedMediaUploadRequest = { + media_id: initUploadRes.data.id, + media: await imgBlob.blob, + segment_index: 0, + } + this.logger.log('chunkedMediaUploadRequest', uploadReq) + const updateRes = await this.chunkedMediaUploadRequest( + accountId, + uploadReq, + ) + this.logger.log(updateRes) + if (!updateRes || !updateRes.data.expires_at) { + this.logger.error('图片分片上传失败') + return null + } + const finalizeUploadRes = await this.finalizeMediaUpload( + accountId, + initUploadRes.data.id, + ) + this.logger.log(finalizeUploadRes) + if (!finalizeUploadRes || !finalizeUploadRes.data.id) { + this.logger.error('确认图片上传失败') + return null + } + twitterMediaIDs.push(initUploadRes.data.id) + } + } + + if (videoUrl) { + const fileName = getFileTypeFromUrl(videoUrl, true) + const ext = fileName.split('.').pop()?.toLowerCase() + const mimeType = ext === 'mp4' ? 'video/mp4' : `video/${ext}` + + const contentLength = await getFileSizeFromUrl(videoUrl) + if (!contentLength) { + this.logger.error('视频信息解析失败') + return null + } + const initUploadReq: XMediaUploadInitRequest = { + media_type: mimeType as XMediaType, + total_bytes: contentLength, + media_category: XMediaCategory.TWEET_VIDEO, + shared: false, + } + + const initUploadRes = await this.initMediaUpload( + accountId, + initUploadReq, + ) + this.logger.log(`initMediaUpload: ${JSON.stringify(initUploadRes)}`) + if (!initUploadRes || !initUploadRes.data.id) { + this.logger.error('视频初始化上传失败') + return null + } + const chunkSize = 4 * 1024 * 1024 // 5MB + + const totalParts = Math.ceil(contentLength / chunkSize) + for (let partNumber = 0; partNumber < totalParts; partNumber++) { + const start = partNumber * chunkSize + const end = Math.min(start + chunkSize - 1, contentLength - 1) + const range: [number, number] = [start, end] + const videoBlob = await chunkedDownloadFile(videoUrl, range) + if (!videoBlob) { + this.logger.error('视频分片下载失败') + return null + } + this.logger.log(`videoBlob ${partNumber}, ${videoBlob.length}, range: ${range}`) + const uploadReq: XChunkedMediaUploadRequest = { + media: new Blob([videoBlob]), + media_id: initUploadRes.data.id, + segment_index: partNumber, + } + this.logger.log(`chunkedMediaUploadRequest: ${JSON.stringify(uploadReq)}`) + const upload = await this.chunkedMediaUploadRequest( + accountId, + uploadReq, + ) + this.logger.log(`chunkedMediaUploadRequest: ${JSON.stringify(upload)}`) + if (!upload || !upload.data.expires_at) { + this.logger.error('视频分片上传失败') + return null + } + } + const finalizeUploadRes = await this.finalizeMediaUpload( + accountId, + initUploadRes.data.id, + ) + this.logger.log(`finalizeMediaUpload: ${JSON.stringify(finalizeUploadRes)}`) + if (!finalizeUploadRes || !finalizeUploadRes.data.id) { + this.logger.error('确认视频上传完成失败') + return null + } + twitterMediaIDs.push(initUploadRes.data.id) + } + this.logger.log(`twitterMediaIDs: ${twitterMediaIDs}`) + const status = await this.getMediaUploadStatus( + accountId, + twitterMediaIDs[0], + ) + this.logger.log(`getMediaUploadStatus: ${JSON.stringify(status)}`) + } + + async getUserTimeline( + accountId: string, + userId: string, + queryDto: UserTimelineDto, + ) { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const query: XUserTimelineRequest = { + start_time: queryDto.startTime, + end_time: queryDto.endTime, + since_id: queryDto.sinceId, + until_id: queryDto.untilId, + max_results: queryDto.maxResults ? Number.parseInt(queryDto.maxResults) : 10, + exclude: queryDto.exclude, + } + return await this.twitterApiService.getUserTimeline( + userId, + credential.access_token, + query, + ) + } + + async getUserPosts( + accountId: string, + userId: string, + queryDto: UserTimelineDto, + ) { + const credential = await this.authorize(accountId) + if (!credential) { + this.logger.warn(`No access token found for accountId: ${accountId}`) + return null + } + const query: XUserTimelineRequest = { + 'start_time': queryDto.startTime, + 'end_time': queryDto.endTime, + 'since_id': queryDto.sinceId, + 'until_id': queryDto.untilId, + 'max_results': queryDto.maxResults ? Number.parseInt(queryDto.maxResults) : 10, + 'exclude': queryDto.exclude, + 'media.fields': ['url', 'preview_image_url'], + } + return await this.twitterApiService.getUserPosts( + userId, + credential.access_token, + query, + ) + } + + async getTweetDetail( + userId: string, + tweetId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.getPostDetail( + credential.access_token, + tweetId, + ) + } + + async repost( + userId: string, + tweetId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.repost( + userId, + credential.access_token, + tweetId, + ) + } + + async unRepost( + userId: string, + tweetId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.unRepost( + userId, + credential.access_token, + tweetId, + ) + } + + async likePost( + userId: string, + tweetId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.likePost( + userId, + credential.access_token, + tweetId, + ) + } + + async unlikePost( + userId: string, + tweetId: string, + ): Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + return await this.twitterApiService.unlikePost( + userId, + credential.access_token, + tweetId, + ) + } + + public async replyPost(userId: string, tweetId: string, text: string): + Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + const post: XCreatePostRequest = { + text, + reply: { + in_reply_to_tweet_id: tweetId, + }, + } + return await this.twitterApiService.createPost(credential.access_token, post) + } + + public async quotePost(userId: string, tweetId: string, text: string): + Promise { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + const post: XCreatePostRequest = { + text, + quote_tweet_id: tweetId, + } + return await this.twitterApiService.createPost(credential.access_token, post) + } + + async getAccessTokenStatus(accountId: string): Promise { + const credential = await this.getOAuth2Credential(accountId) + if (!credential) { + this.logger.warn(`No access token found for twitter accountId: ${accountId}`) + return 0 + } + return 1 + } + + async deleteTweet(userId: string, tweetId: string): Promise<{ success: boolean } | null> { + const credential = await this.authorize(userId) + if (!credential) { + this.logger.warn(`No access token found for userId: ${userId}`) + return null + } + const resp = await this.twitterApiService.deleteTweet(credential.access_token, tweetId) + return { success: resp.data.deleted } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/WXMsgCrypto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/WXMsgCrypto.ts new file mode 100644 index 000000000..12a9044c4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/WXMsgCrypto.ts @@ -0,0 +1,53 @@ +import * as crypto from 'node:crypto' + +const ALGORITHM = 'aes-256-cbc' // 使用的加密算法 +const MSG_LENGTH_SIZE = 4 // 存放消息体尺寸的空间大小。单位:字节 +const RANDOM_BYTES_SIZE = 16 // 随机数据的大小。单位:字节 + +/** + * 解密数据 + * @param {*} encryptdMsg 加密消息体 + * @param {*} encodingAESKey AES 加密密钥 + * @returns + */ +export function decode(encryptdMsg: string, encodingAESKey: string) { + const key = Buffer.from(`${encodingAESKey}=`, 'base64') // 解码密钥 + const iv = key.subarray(0, 16) // 初始化向量为密钥的前16字节 + const encryptedMsgBuf = Buffer.from(encryptdMsg, 'base64') // 将 base64 编码的数据转成 buffer + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv) // 创建解密器实例 + decipher.setAutoPadding(false) // 禁用默认的数据填充方式 + let decryptdBuf = Buffer.concat([ + decipher.update(encryptedMsgBuf), + decipher.final(), + ]) // 解密后的数据 + + const padSize = decryptdBuf[decryptdBuf.length - 1] + decryptdBuf = decryptdBuf.subarray(0, decryptdBuf.length - padSize) // 去除填充的数据 + + const msgSize = decryptdBuf.readUInt32BE(RANDOM_BYTES_SIZE) // 根据指定偏移值,从 buffer 中读取消息体的大小,单位:字节 + const msgBufStartPos = RANDOM_BYTES_SIZE + MSG_LENGTH_SIZE // 消息体的起始位置 + const msgBufEndPos = msgBufStartPos + msgSize // 消息体的结束位置 + + const msgBuf = decryptdBuf.subarray(msgBufStartPos, msgBufEndPos) // 从 buffer 中提取消息体 + + return msgBuf.toString() // 将消息体转成字符串,并返回数据 +} + +/** + * 生成签名 + * @param {*} encrypt 加密消息体 + * @param {*} timestamp 时间戳 + * @param {*} nonce 随机数 + * @param {*} token 令牌 + * @returns + */ +export function genSign( + encrypt: string, + timestamp: string, + nonce: string, + token: string, +) { + const rawStr = [token, timestamp, nonce, encrypt].sort().join('') // 原始字符串 + const signature = crypto.createHash('sha1').update(rawStr).digest('hex') // 计算签名 + return signature +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/common.ts new file mode 100644 index 000000000..36a9fef43 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/common.ts @@ -0,0 +1,5 @@ +export interface WxPlatAuthInfo { + userId: string + createTime: number + accountId?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/dto/wxPlat.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/dto/wxPlat.dto.ts new file mode 100644 index 000000000..4ae2f85e5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/dto/wxPlat.dto.ts @@ -0,0 +1,117 @@ +import { Expose, Type } from 'class-transformer' +import { + IsBoolean, + IsNumber, + IsObject, + IsOptional, + IsString, +} from 'class-validator' +import { AddArchiveData } from '../../../../libs/bilibili/common' + +export class AccountIdDto { + @IsNumber({ allowNaN: false }, { message: '账号ID' }) + @Type(() => Number) + @Expose() + readonly accountId: number +} + +export class UserIdDto { + @IsString({ message: '用户ID' }) + @Expose() + readonly userId: string +} + +export class GetAuthUrlDto extends UserIdDto { + @IsString({ message: '空间ID' }) + @Expose() + readonly spaceId: string + + @IsString({ message: '类型 pc h5' }) + @Expose() + readonly type: 'pc' | 'h5' + + @IsString({ message: '前缀' }) + @IsOptional() + @Expose() + readonly prefix?: string +} + +export class DisposeAuthTaskDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string + + @IsString({ message: '授权码' }) + @Expose() + readonly auth_code: string + + @IsNumber({ allowNaN: false }, { message: '过期时间' }) + @Type(() => Number) + @Expose() + readonly expires_in: number +} + +export class AuthBackParamDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string + + @IsString({ message: '前缀' }) + @IsOptional() + @Expose() + readonly prefix?: string +} + +export class AuthBackQueryDto { + @IsString({ message: '授权码' }) + @Expose() + readonly auth_code: string + + @IsNumber({ allowNaN: false }, { message: '过期时间' }) + @Type(() => Number) + @Expose() + readonly expires_in: number +} + +export class GetAuthInfoDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string +} + +export class GetHeaderDto extends AccountIdDto { + @IsObject({ message: '数据' }) + @Expose() + readonly body: { [key: string]: any } + + @IsBoolean({ message: '是否是表单提交' }) + @Expose() + readonly isForm: boolean +} + +export class VideoInitDto extends AccountIdDto { + @IsNumber( + { allowNaN: false }, + { + message: + '上传类型:0,1。0-多分片,1-单个小文件(不超过100M)。默认值为0', + }, + ) + @Type(() => Number) + @Expose() + readonly utype: number // 0 1 + + @IsString({ message: '文件名称' }) + @Expose() + readonly name: string +} + +export class AddArchiveDto extends AccountIdDto { + @IsObject({ message: '数据' }) + @Expose() + readonly data: AddArchiveData + + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxGzh.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxGzh.service.ts new file mode 100644 index 000000000..6e6aaa024 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxGzh.service.ts @@ -0,0 +1,179 @@ +import { Injectable } from '@nestjs/common' +import { fileUrlToBlob } from '../../../common' +import { FileService } from '../../../core/file/file.service' +import { + MediaType, + WxGzhArticleNews, + WxGzhArticleNewsPic, +} from '../../../libs/wxGzh/common' +import { WxGzhApiService } from '../../../libs/wxGzh/wxGzhApi.service' +import { WxPlatService } from './wxPlat.service' + +@Injectable() +export class WxGzhService { + constructor( + private readonly wxGzhApiService: WxGzhApiService, + private readonly fileService: FileService, + private readonly wxPlatService: WxPlatService, + ) {} + + /** + * 获取token + * @param accountId + * @returns + */ + async getAccessToken(accountId: string) { + const res = await this.wxPlatService.getAuthorizerAccessToken(accountId) + return res.authorizer_access_token + } + + async checkAuth(accountId: string) { + const res = await this.wxPlatService.checkAuth(accountId) + return res + } + + /** + * 上传临时素材 + * @param accessToken + * @param media + * @param type + * @returns + */ + async uploadTempMedia( + accountId: string, + type: MediaType, + url: string, + ) { + const blobFile = await fileUrlToBlob(this.fileService.filePathToUrl(url)) + const res = await this.wxGzhApiService.uploadTempMedia( + await this.getAccessToken(accountId), + type, + blobFile.blob, + blobFile.fileName, + ) + return res + } + + /** + * 获取临时素材 + * @param accountId + * @param mediaId + * @returns + */ + async getTempMedia(accountId: string, mediaId: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.getTempMedia(accessToken, mediaId) + return res + } + + /** + * 上传图文中的图片素材(不占用限制) + * @param file + * @returns + */ + async uploadImg(accountId: string, imgUrl: string) { + const accessToken = await this.getAccessToken(accountId) + const blobFile = await fileUrlToBlob(this.fileService.filePathToUrl(imgUrl)) + const res = await this.wxGzhApiService.uploadImg(accessToken, blobFile.blob, blobFile.fileName) + return res + } + + /** + * 上传永久素材 + * @param accountId + * @param type + * @param file + * @returns + */ + async addMaterial( + accountId: string, + type: MediaType, + fileUrl: string, + videoOptions?: { + title: string + introduction?: string + }, + ) { + const accessToken = await this.getAccessToken(accountId) + const blobFile = await fileUrlToBlob(this.fileService.filePathToUrl(fileUrl)) + const res = await this.wxGzhApiService.addMaterial( + accessToken, + type, + blobFile.blob, + blobFile.fileName, + videoOptions, + ) + return res + } + + /** + * 获取永久素材 + * @param accountId + * @param mediaId + * @returns + */ + async getMaterial(accountId: string, mediaId: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.getMaterial(accessToken, mediaId) + return res + } + + /** + * 新建草稿 + * @param accessToken + * @param data + * @returns + */ + async draftAdd( + accountId: string, + data: WxGzhArticleNews | WxGzhArticleNewsPic, + ) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.draftAdd(accessToken, data) + return res + } + + /** + * 发布 + * @param accountId + * @param mediaId + * @returns + */ + async freePublish(accountId: string, mediaId: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.freePublish(accessToken, mediaId) + return res + } + + /** + * 获取累计用户数据 + * @param accountId + * @param beginDate yyyy-MM-dd + * @param endDate + * @returns + */ + async getusercumulate(accountId: string, beginDate: string, endDate: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.getusercumulate(accessToken, beginDate, endDate) + return res + } + + /** + * 获取图文阅读概况数据 + * @param accountId + * @param beginDate yyyy-MM-dd + * @param endDate + * @returns + */ + async getuserread(accountId: string, beginDate: string, endDate: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.getuserread(accessToken, beginDate, endDate) + return res + } + + async deleteArticle(accountId: string, mediaId: string) { + const accessToken = await this.getAccessToken(accountId) + const res = await this.wxGzhApiService.deleteArticle(accessToken, mediaId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.controller.ts new file mode 100644 index 000000000..595f647ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.controller.ts @@ -0,0 +1,120 @@ +import { Body, Controller, Delete, Logger, Param, Post } from '@nestjs/common' +import { PublishRecordService } from '../../../core/account/publishRecord.service' +import { + DisposeAuthTaskDto, + GetAuthUrlDto, +} from './dto/wxPlat.dto' +import { WxGzhService } from './wxGzh.service' +import { WxPlatService } from './wxPlat.service' + +@Controller() +export class WxPlatController { + logger = new Logger(WxPlatController.name) + constructor( + private readonly wxPlatService: WxPlatService, + private readonly wxGzhService: WxGzhService, + private readonly publishRecordService: PublishRecordService, + ) { } + + /** + * 更新发布结果 + * @param data + * @returns + */ + // @NatsMessagePattern('channel.wxPlat.updatePublishRecord') + @Post('channel/wxPlat/updatePublishRecord') + async updatePublishRecord(@Body() data: { + publish_id: string + appId: string + article_url?: string + article_id: string + }) { + const res = await this.publishRecordService.donePublishRecord({ dataId: data.publish_id, uid: data.appId }, { + workLink: + data.article_url || `https://mp.weixin.qq.com/s/${data.article_id}`, + dataOption: { + $set: { + article_id: data.article_id, + }, + }, + }) + + return res + } + + /** + * 创建授权任务 + * @param data + * @returns + */ + // @NatsMessagePattern('plat.wxPlat.auth') + @Post('plat/wxPlat/auth') + createAuthTask(@Body() data: GetAuthUrlDto) { + const res = this.wxPlatService.createAuthTask( + { + userId: data.userId, + type: data.type, + spaceId: data.spaceId, + }, + { + transpond: data.prefix, + }, + ) + + return res + } + + /** + * 获取账号授权信息 + */ + // @NatsMessagePattern('plat.wxPlat.getAuthInfo') + @Post('plat/wxPlat/getAuthInfo') + async getAuthInfo(@Body() data: { taskId: string }) { + const res = await this.wxPlatService.getAuthTaskInfo(data.taskId) + return res + } + + /** + * 处理用户的账号授权 + * @param data + * @returns + */ + // @NatsMessagePattern('channel.wxPlat.createAccountAndSetAccessToken') + @Post('channel/wxPlat/createAccountAndSetAccessToken') + async disposeAuthTask(@Body() data: DisposeAuthTaskDto) { + const res = await this.wxPlatService.createAccountAndSetAccessToken( + data.taskId, + { + authCode: data.auth_code, + expiresIn: data.expires_in, + }, + ) + return res + } + + /** + * 获取累计用户数据 + */ + // @NatsMessagePattern('plat.wxPlat.getUserCumulate') + @Post('plat/wxPlat/getUserCumulate') + async getUserCumulate(@Body() data: { accountId: string, beginDate: string, endDate: string }) { + const res = await this.wxGzhService.getusercumulate(data.accountId, data.beginDate, data.endDate) + return res + } + + /** + * 获取图文阅读概况数据 + */ + // @NatsMessagePattern('plat.wxPlat.getUserRead') + @Post('plat/wxPlat/getUserRead') + async getUserRead(@Body() data: { accountId: string, beginDate: string, endDate: string }) { + const res = await this.wxGzhService.getuserread(data.accountId, data.beginDate, data.endDate) + return res + } + + @Delete(':accountId/articles/:articleId') + async deleteArticle(@Param('accountId') accountId: string, @Param('articleId') articleId: string) { + const res = await this.wxGzhService.deleteArticle(accountId, articleId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.module.ts new file mode 100644 index 000000000..57580e681 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.module.ts @@ -0,0 +1,15 @@ +import { forwardRef, Module } from '@nestjs/common' +import { PublishModule } from '../../../core/publish/publish.module' +import { MyWxPlatApiModule } from '../../../libs/myWxPlat/myWxPlatApi.module' +import { WxGzhApiModule } from '../../../libs/wxGzh/wxGzhApi.module' +import { WxGzhService } from './wxGzh.service' +import { WxPlatController } from './wxPlat.controller' +import { WxPlatService } from './wxPlat.service' + +@Module({ + imports: [MyWxPlatApiModule, WxGzhApiModule, forwardRef(() => PublishModule)], + controllers: [WxPlatController], + providers: [WxPlatService, WxGzhService], + exports: [WxPlatService, WxGzhService], +}) +export class WxPlatModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.service.ts new file mode 100644 index 000000000..4bb064f0c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/wxPlat/wxPlat.service.ts @@ -0,0 +1,300 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: WxPlat + */ +import { Injectable, Logger } from '@nestjs/common' +import { AccountStatus, AccountType, NewAccount } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import { v4 as uuidv4 } from 'uuid' +import { ExceptionCode } from '../../../common/enums/exception-code.enum' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { MyWxPlatApiService } from '../../../libs/myWxPlat/myWxPlatApi.service' +import { WxPlatAuthorizerInfo } from '../../../libs/wxPlat/comment' +import { AuthTaskInfo } from '../common' +import { WxPlatAuthInfo } from './common' +import { decode } from './WXMsgCrypto' + +@Injectable() +export class WxPlatService { + private encodingAESKey = '' + private readonly logger = new Logger(WxPlatService.name) + + constructor( + private readonly redisService: RedisService, + private readonly myWxPlatApiService: MyWxPlatApiService, + private readonly accountService: AccountService, + ) { + this.encodingAESKey = config.wxPlat.encodingAESKey + } + + private getAuthDataCacheKey(taskId: string) { + return `channel:wxPlat:authTask:${taskId}` + } + + // 公众号token缓存key + private getAuthAccessTokenCacheKey(accountId: string) { + return `channel:wxPlat:authorizerAccessToken:${accountId}` + } + + // 公众号token缓存key + private getAuthRefreshTokenCacheKey(accountId: string) { + return `channel:wxPlat:authorizerRefreshToken:${accountId}` + } + + decryptWXData(data: string) { + return decode(data, this.encodingAESKey) + } + + /** + * 创建用户授权任务 + */ + async createAuthTask( + data: { + userId: string + type: 'h5' | 'pc' + spaceId: string + }, + options?: { + transpond?: string + accountAddPath?: string + }, + ) { + const taskId = uuidv4() + + const authUrl = await this.getAuthPageUrl(data.type, taskId) + if (!authUrl) + throw new AppException(ExceptionCode.Failed, '不存在平台授权令牌') + + const rRes = await this.redisService.setJson( + this.getAuthDataCacheKey(taskId), + { + taskId, + spaceId: data.spaceId, + transpond: options?.transpond, + accountAddPath: options?.accountAddPath, + data: { + createTime: Date.now(), + userId: data.userId, + }, + status: 0, + }, + 60 * 5, + ) + + if (!rRes) + throw new AppException(ExceptionCode.Failed, '创建授权任务失败') + + return { + url: authUrl, + taskId, + } + } + + // 获取授权任务信息 + async getAuthTaskInfo(taskId: string) { + const taskInfo = await this.redisService.getJson>( + this.getAuthDataCacheKey(taskId), + ) + + return taskInfo + } + + /** + * 获取授权页面链接 + * @param type + * @param stat + * @returns + */ + async getAuthPageUrl(type: 'h5' | 'pc', stat?: string): Promise { + const res = await this.myWxPlatApiService.getAuthPageUrl(type, stat) + if (!res) + throw new AppException(ExceptionCode.Failed, '不存在平台授权令牌') + + return res + } + + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + const refreshToken = await this.redisService.get(this.getAuthRefreshTokenCacheKey(accountId)) + if (!refreshToken) { + return { + status: 0, + } + } + + const timeout = await this.redisService.ttl(this.getAuthRefreshTokenCacheKey(accountId)) + return { + status: 1, + timeout: timeout / 1000, + } + } + + /** + * (通过授权页面)设置用户的授权配置并创建账号 + * @param taskId + * @param authData + */ + async createAccountAndSetAccessToken( + taskId: string, + authData: { authCode: string, expiresIn: number }, + ) { + try { + const taskInfo = await this.redisService.getJson>( + this.getAuthDataCacheKey(taskId), + ) + if (!taskInfo || !taskInfo.data) + return { status: 0, message: '任务不存在或已完成' } + if (taskInfo.status === 1) + return { status: 0, message: '任务已完成' } + + // 计算是否超时 + if (Date.now() - taskInfo.data.createTime > authData.expiresIn * 1000) { + void this.redisService.del(this.getAuthDataCacheKey(taskId)) + return { status: 0, message: '任务已超时' } + } + + // 延长授权时间 + void this.redisService.expire(this.getAuthDataCacheKey(taskId), 60 * 3) + + // 根据授权码获取授权信息 + const auth = await this.myWxPlatApiService.getQueryAuth(authData.authCode) + if (!auth) { + void this.redisService.del(this.getAuthDataCacheKey(taskId)) + return { status: 0, message: '获取授权信缓存失败' } + } + const { authorizer_appid, expires_in } = auth + + const authInfo = await this.myWxPlatApiService.getAuthorizerInfo(authorizer_appid) + if (!authInfo) + return { status: 0, message: '获取授权信息失败' } + + // 创建本平台的平台账号 + const newData = new NewAccount({ + userId: taskInfo.data.userId, + type: AccountType.WxGzh, + uid: authorizer_appid, + account: authInfo.user_name, + avatar: authInfo.head_img, + nickname: authInfo.nick_name, + groupId: taskInfo.spaceId, + status: AccountStatus.NORMAL, + }) + + const accountInfo = await this.accountService.createAccount( + taskInfo.data.userId, + { + type: AccountType.WxGzh, + uid: authorizer_appid, + }, + newData, + ) + if (!accountInfo) + return { status: 0, message: '添加账号失败' } + + // 设置授权信息 + const setRes = await this.redisService.setJson( + this.getAuthAccessTokenCacheKey(accountInfo.id), + auth, + expires_in, + ) + + // 设置29天的刷新令牌 + await this.redisService.setJson( + this.getAuthRefreshTokenCacheKey(accountInfo.id), + auth.authorizer_refresh_token, + 2592000, + ) + + if (!setRes) + return { status: 0, message: '设置授权信息缓存失败' } + + // 更新任务信息 + taskInfo.status = 1 + taskInfo.data.accountId = accountInfo.id + + const res = await this.redisService.setJson( + this.getAuthDataCacheKey(taskId), + taskInfo, + 60 * 5, + ) + if (!res) + return { status: 0, message: '更新任务信息失败' } + + return { status: 1, message: '添加账号成功', accountId: accountInfo.id } + } + catch (error) { + this.logger.error('createAccountAndSetAccessToken error:', error) + return { status: 0, message: `添加账号失败: ${error.message}` } + } + } + + /** + * 获取授权方接口调用凭据 + * @param accountId + */ + async getAuthorizerAccessToken(accountId: string) { + const accountInfo = await this.accountService.getAccountInfo(accountId) + if (!accountInfo) + throw new Error('账号不存在') + + try { + const info = await this.redisService.getJson( + this.getAuthAccessTokenCacheKey(accountId), + ) + if (info) { + // 快超时就重新获取 + const overTime = await this.redisService.ttl( + this.getAuthAccessTokenCacheKey(accountId), + ) + if (overTime < 60 * 10) + return info + + const newInfo = await this.myWxPlatApiService.getAuthorizerAccessToken( + info.authorizer_appid, + info.authorizer_refresh_token, + ) + if (!newInfo) + throw new Error('获取授权方令牌失败') + + const res = await this.redisService.setJson( + this.getAuthAccessTokenCacheKey(accountId), + newInfo, + newInfo.expires_in, + ) + if (!res) + throw new Error('设置授权方令牌缓存失败') + + return newInfo + } + + // 没有值重新获取 + // 查看长期的刷新令牌 + const refreshToken = await this.redisService.get( + this.getAuthRefreshTokenCacheKey(accountId), + ) + + if (!refreshToken) + throw new Error('获取授权方刷新令牌失败') + + const newInfo = await this.myWxPlatApiService.getAuthorizerAccessToken( + accountInfo.uid, + refreshToken, + ) + + if (!newInfo) + throw new Error('获取授权方令牌失败') + return newInfo + } + catch (error) { + this.logger.error(error) + throw new AppException(ExceptionCode.Failed, error) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/comment.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/dto/youtube.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/dto/youtube.dto.ts new file mode 100644 index 000000000..3bc5d9b8a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/dto/youtube.dto.ts @@ -0,0 +1,753 @@ +import { Expose, Transform, Type } from 'class-transformer' +import { + IsBoolean, + IsEmail, + IsNumber, + IsObject, + IsOptional, + IsString, +} from 'class-validator' + +// 定义类型 +export interface YoutubePlaylistSnippet { + title?: string + description?: string + defaultLanguage?: string +} + +export interface YoutubePlaylistStatus { + privacyStatus?: string + podcastStatus?: string +} + +export interface YouTubeAuthTokens { + accessToken: string + refreshToken?: string + expiresAt?: number +} + +export class UserIdDto { + @IsString({ message: '用户ID' }) + @Expose() + readonly userId: string +} + +export class GetAuthUrlDto extends UserIdDto { + // @IsString({ message: '类型 pc h5' }) + // @Expose() + // readonly type: 'h5' | 'pc' + + @IsString({ message: '空间ID' }) + @Expose() + readonly spaceId: string + + @IsEmail({}, { message: '邮箱' }) + @Expose() + readonly mail: string + + @IsString({ message: '前缀' }) + @IsOptional() + @Expose() + readonly prefix?: string +} + +export class GetAuthInfoDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string +} + +export class AccountIdDto { + @IsString({ message: '账号ID' }) + @Expose() + readonly accountId: string +} + +export class VideoCategoriesDto extends AccountIdDto { + @IsString({ message: '视频类别id' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsString({ message: '区域代码' }) + @IsOptional() + @Expose() + readonly regionCode?: string +} + +export class VideosListDto extends AccountIdDto { + @IsString({ message: '图表' }) + @IsOptional() + @Expose() + readonly chart?: string + + @IsString({ message: '视频类别id' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsBoolean({ message: '是否喜欢' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly myRating?: boolean + + @IsNumber({}, { message: '最大结果数' }) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string +} + +export class CreateAccountAndSetAccessTokenDto { + @IsString({ message: '任务ID' }) + @Expose() + readonly taskId: string + + @IsString({ message: '授权码' }) + @Expose() + readonly code: string + + @IsString({ message: '状态' }) + @Expose() + readonly state: string +} + +export class UploadVideoDto extends AccountIdDto { + @IsObject({ message: '视频文件Buffer' }) + @Expose() + readonly fileBuffer: Buffer + + @IsString({ message: '文件名' }) + @Expose() + readonly fileName: string + + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '描述' }) + @Expose() + readonly description: string + + @IsString({ message: '隐私状态' }) + @Expose() + readonly privacyStatus: string + + @IsString({ message: '关键词' }) + @IsOptional() + @Expose() + readonly keywords?: string + + @IsString({ message: '类别ID' }) + @IsOptional() + @Expose() + readonly categoryId?: string + + // @IsString({ message: '发布时间' }) + // @IsOptional() + // @Expose() + // readonly publishAt?: string +} + +export class UploadLitVideoDto extends AccountIdDto { + @IsString({ message: '文件流 base64编码' }) + @Expose() + readonly file: string + + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string +} + +export class UploadVideoPartDto extends AccountIdDto { + @IsString({ message: '文件流 base64编码' }) + @Expose() + readonly fileBase64: string + + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string + + @IsNumber({ allowNaN: false }, { message: '分片索引' }) + @Type(() => Number) // 关键在这里,确保类型转换 + @Expose() + readonly partNumber: number +} + +export class VideoCompleteDto extends AccountIdDto { + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string + + @IsNumber({ allowNaN: false }, { message: '文件总大小' }) + @Type(() => Number) + @Expose() + readonly totalSize: number +} + +export class InitUploadVideoDto extends AccountIdDto { + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '描述' }) + @Expose() + readonly description: string + + @IsString({ message: '隐私状态' }) + @Expose() + readonly privacyStatus: string + + @IsString({ message: '关键词' }) + @IsOptional() + @Expose() + readonly tag?: string + + @IsString({ message: '类别ID' }) + @IsOptional() + @Expose() + readonly categoryId?: string + + // @IsString({ message: '发布时间' }) + // @IsOptional() + // @Expose() + // readonly publishAt?: string + + @IsNumber() + @IsOptional() + @Type(() => Number) + @Expose() + readonly contentLength?: number + + @IsString({ message: '许可证' }) + @IsOptional() + @Expose() + readonly license?: string + + @IsBoolean({ message: '是否可嵌入' }) + @IsOptional() + @Expose() + readonly embeddable?: boolean + + @IsBoolean({ message: '是否通知订阅者' }) + @IsOptional() + @Expose() + readonly notifySubscribers?: boolean + + @IsBoolean({ message: '是否自认为适合儿童' }) + @IsOptional() + @Expose() + readonly selfDeclaredMadeForKids?: boolean +} + +// 获取频道列表 +export class GetChannelsListDto extends AccountIdDto { + @IsString({ message: '标识名,注意:forHandle、forUsername、id、mine 必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly forHandle?: string + + @IsString({ message: '用户名,注意:forHandle、forUsername、id、mine 必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly forUsername?: string + + @IsString({ message: '频道ID,注意:forHandle、forUsername、id、mine 必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsBoolean({ message: '我的频道,注意:forHandle、forUsername、id、mine 必须有且只能有一个' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly mine?: boolean + + @IsNumber({ allowNaN: false }, { message: '最大结果数' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string +} + +export class UpdateChannelsDto extends AccountIdDto { + @IsString({ message: '频道ID' }) + @Expose() + readonly id: string + + @IsString({ message: 'handle' }) + @IsOptional() + @Expose() + readonly handle?: string + + @IsString({ message: '用户名' }) + @IsOptional() + @Expose() + readonly userName?: string + + @IsString({ message: 'mine' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly mine?: boolean +} + +export class GetCommentsListDto extends AccountIdDto { + @IsString({ message: '评论ID,多个id用英文逗号分隔,注意:id、parentId,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly id: string + + @IsString({ message: '顶级评论ID。注意:id、parentId,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly parentId?: string + + @IsNumber({ allowNaN: false }, { message: '最大结果数' }) + @Type(() => Number) + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string +} + +// 创建顶级评论(评论会话) +export class InsertCommentThreadsDto extends AccountIdDto { + @IsString({ message: '频道ID' }) + @Expose() + readonly channelId: string + + @IsString({ message: '视频ID' }) + @Expose() + readonly videoId: string + + @IsString({ message: '评论内容' }) + @Expose() + readonly textOriginal: string +} + +// 获取评论会话列表 +export class GetCommentThreadsListDto extends AccountIdDto { + @IsString({ message: '评论会话 ID(多个id以英文逗号分隔)注意:id、allThreadsRelatedToChannelId、videoId 必须有且只能有一个,不能同时使用' }) + @Expose() + @IsOptional() + readonly id?: string + + @IsString({ message: '关联的频道ID 注意:id、allThreadsRelatedToChannelId、videoId 必须有且只能有一个,不能同时使用' }) + @Expose() + @IsOptional() + readonly allThreadsRelatedToChannelId?: string + + @IsString({ message: '视频ID 注意:id、allThreadsRelatedToChannelId、videoId 必须有且只能有一个,不能同时使用' }) + @Expose() + @IsOptional() + readonly videoId?: string + + @IsNumber({ allowNaN: false }, { message: '最大结果数' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌,注意:此参数不能与 id 参数结合使用。' }) + @IsOptional() + @Expose() + readonly pageToken?: string + + @IsString({ message: '排序方式,注意:此参数不能与 id 参数结合使用。time - 默认,评论会话会按时间排序。relevance - 评论会话按相关性排序。' }) + @IsOptional() + @Expose() + readonly order?: string + + @IsString({ message: '搜索关键词,注意:此参数不能与 id 参数结合使用。' }) + @IsOptional() + @Expose() + readonly searchTerms?: string +} + +// 创建二级评论 +export class InsertCommentDto extends AccountIdDto { + @IsString({ message: '父评论ID' }) + @IsOptional() + @Expose() + readonly parentId: string + + @IsString({ message: '评论内容' }) + @IsOptional() + @Expose() + readonly textOriginal: string +} + +// 更新评论 +export class UpdateCommentDto extends AccountIdDto { + @IsString({ message: '评论ID' }) + @IsOptional() + @Expose() + readonly id: string + + @IsString({ message: '评论内容必须是字符串' }) + @IsOptional() + @Expose() + readonly textOriginal: string +} + +// 设置评论审核状态 +export class SetCommentThreadsModerationStatusDto extends AccountIdDto { + @IsString({ message: '评论ID' }) + @Expose() + readonly id: string + + @IsString({ message: '审核状态,heldForReview 等待管理员审核 published - 清除要公开显示的评论。 rejected - 不显示该评论' }) + @Expose() + readonly moderationStatus: string + + @IsBoolean({ message: '是否自动拒绝评论作者撰写的任何其他评论 将作者加入黑名单,' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly banAuthor?: boolean +} + +// 删除评论 +export class DeleteCommentDto extends AccountIdDto { + @IsString({ message: '评论ID' }) + @Expose() + readonly id: string +} + +// 对视频的点赞、踩 +export class VideoRateDto extends AccountIdDto { + @IsString({ message: '视频ID' }) + @Expose() + readonly id: string + + @IsString({ message: '点赞、踩 like/dislike/none' }) + @Expose() + readonly rating: string +} + +// 获取视频的点赞、踩 +export class GetVideoRateDto extends AccountIdDto { + @IsString({ message: '视频ID,多个id用英文逗号分隔' }) + @Expose() + readonly id: string +} + +// 删除视频 +export class DeleteVideoDto extends AccountIdDto { + @IsString({ message: '视频ID' }) + @Expose() + readonly id: string +} + +// 更新视频 +export class UpdateVideoDto extends AccountIdDto { + @IsString({ message: '视频ID' }) + @Expose() + readonly id: string + + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '类别ID' }) + @Expose() + readonly categoryId: string + + @IsString({ message: '默认语言' }) + @IsOptional() + @Expose() + readonly defaultLanguage: string + + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly description: string + + @IsString({ message: '隐私状态' }) + @IsOptional() + @Expose() + readonly privacyStatus: string + + @IsString({ message: '标签' }) + @IsOptional() + @Expose() + readonly tags?: string + + @IsString({ message: '发布时间' }) + @IsOptional() + @Expose() + readonly publishAt?: string + + @IsString({ message: '录制日期' }) + @IsOptional() + @Expose() + readonly recordingDate?: string +} + +// 创建播放列表 +export class InsertPlayListDto extends AccountIdDto { + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly description?: string + + @IsString({ message: '隐私状态' }) + @IsOptional() + @Expose() + readonly privacyStatus?: string +} + +// 获取播放列表 +export class GetPlayListDto extends AccountIdDto { + @IsString({ message: '频道ID, 注意:channelId、id、mine,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly channelId?: string + + @IsString({ message: '播放列表ID, 多个id用英文逗号分隔,注意:channelId、id、mine,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsBoolean({ message: '我的播放列表, 注意:channelId、id、mine,必须有且只能有一个' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly mine?: boolean + + @IsNumber({ allowNaN: false }, { message: '最大结果数' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string +} + +// 更新播放列表 +export class UpdatePlayListDto extends AccountIdDto { + @IsString({ message: '播放列表ID' }) + @Expose() + readonly id: string + + @IsString({ message: '标题' }) + @Expose() + readonly title: string + + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly description?: string + + @IsString({ message: '隐私状态' }) + @IsOptional() + @Expose() + readonly privacyStatus?: string + + @IsString({ message: '播客状态' }) + @IsOptional() + @Expose() + readonly podcastStatus?: string +} + +// 删除播放列表 +export class DeletePlayListDto extends AccountIdDto { + @IsString({ message: '播放列表ID' }) + @Expose() + readonly id: string +} + +// 获取播放列表项 +export class GetPlayItemsDto extends AccountIdDto { + @IsString({ message: '播放列表项ID, 多个id用英文逗号分隔,注意:id、playlistId,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsString({ message: '播放列表ID, 注意:id、playlistId,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly playlistId?: string + + @IsNumber({ allowNaN: false }, { message: '最大结果数' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string + + @IsString({ message: '视频ID' }) + @IsOptional() + @Expose() + readonly videoId?: string +} + +// 插入播放列表项 +export class InsertPlayItemsDto extends AccountIdDto { + @IsString({ message: '播放列表ID' }) + @Expose() + readonly playlistId: string + + @IsString({ message: '资源ID' }) + @Expose() + readonly resourceId: string + + @IsNumber({ allowNaN: false }, { message: '位置' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly position?: number + + @IsString({ message: '说明' }) + @IsOptional() + @Expose() + readonly note?: string + + @IsString({ message: '开始时间' }) + @IsOptional() + @Expose() + readonly startAt?: string + + @IsString({ message: '结束时间' }) + @IsOptional() + @Expose() + readonly endAt?: string +} + +// 更新播放列表项 +export class UpdatePlayItemsDto extends AccountIdDto { + @IsString({ message: '播放列表项ID' }) + @Expose() + readonly id: string + + @IsString({ message: '播放列表ID' }) + @Expose() + readonly playlistId: string + + @IsString({ message: '资源ID' }) + @Expose() + readonly resourceId: string + + @IsNumber({ allowNaN: false }, { message: '位置' }) + @Type(() => Number) + @IsOptional() + @Expose() + readonly position?: number + + @IsString({ message: '说明' }) + @IsOptional() + @Expose() + readonly note?: string + + @IsString({ message: '开始时间' }) + @IsOptional() + @Expose() + readonly startAt?: string + + @IsString({ message: '结束时间' }) + @IsOptional() + @Expose() + readonly endAt?: string +} + +// 删除播放列表项 +export class DeletePlayItemsDto extends AccountIdDto { + @IsString({ message: '播放列表项ID' }) + @Expose() + readonly id: string +} + +// 获取频道板块列表 +export class ChannelsSectionsListDto extends AccountIdDto { + @IsString({ message: '频道ID, 注意:channelId、id、mine,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly channelId?: string + + @IsString({ message: '板块ID, 多个id用英文逗号分隔,注意:channelId、id、mine,必须有且只能有一个' }) + @IsOptional() + @Expose() + readonly id?: string + + @IsBoolean({ message: '是否查询自己的板块, 注意:channelId、id、mine,必须有且只能有一个' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly mine?: boolean +} + +/** + * YouTube搜索接口DTO + */ +export class SearchDto extends AccountIdDto { + @IsBoolean({ message: '是否搜索我的内容' }) + @Transform(({ value }) => value === true || value === 'true' || value === 1 || value === '1') + @IsOptional() + @Expose() + readonly forMine?: boolean + + @IsNumber({}, { message: '最大结果数' }) + @IsOptional() + @Expose() + readonly maxResults?: number + + @IsString({ message: '排序方法' }) + @IsOptional() + @Expose() + readonly order?: 'relevance' | 'date' | 'rating' | 'title' | 'videoCount' | 'viewCount' + + @IsString({ message: '分页令牌' }) + @IsOptional() + @Expose() + readonly pageToken?: string + + @IsString({ message: '发布时间之前' }) + @IsOptional() + @Expose() + readonly publishedBefore?: string + + @IsString({ message: '发布时间之后' }) + @IsOptional() + @Expose() + readonly publishedAfter?: string + + @IsString({ message: '搜索查询字词' }) + @IsOptional() + @Expose() + readonly q?: string + + @IsString({ message: '搜索类型' }) + @IsOptional() + @Expose() + readonly type?: 'video' | 'channel' | 'playlist' + + @IsString({ message: '视频类别ID' }) + @IsOptional() + @Expose() + readonly videoCategoryId?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.controller.ts new file mode 100644 index 000000000..b9fa4ca0b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.controller.ts @@ -0,0 +1,917 @@ +import { Body, Controller, Logger, Param, Post } from '@nestjs/common' + +import { ApiTags } from '@nestjs/swagger' + +import { + AccountIdDto, + ChannelsSectionsListDto, + CreateAccountAndSetAccessTokenDto, + DeleteCommentDto, + DeletePlayItemsDto, + DeletePlayListDto, + DeleteVideoDto, + GetAuthInfoDto, + GetAuthUrlDto, + GetChannelsListDto, + GetCommentsListDto, + GetCommentThreadsListDto, + GetPlayItemsDto, + GetPlayListDto, + GetVideoRateDto, + InitUploadVideoDto, + InsertCommentDto, + InsertCommentThreadsDto, + InsertPlayItemsDto, + InsertPlayListDto, + SearchDto, + SetCommentThreadsModerationStatusDto, + UpdateCommentDto, + UpdatePlayItemsDto, + UpdatePlayListDto, + UpdateVideoDto, + UploadVideoDto, + UploadVideoPartDto, + VideoCategoriesDto, + VideoCompleteDto, + VideoRateDto, + VideosListDto, +} from './dto/youtube.dto' + +import { YoutubeService } from './youtube.service' + +@ApiTags('youtube - Youtube平台') +@Controller() +export class YoutubeController { + private readonly logger = new Logger(YoutubeController.name) + + constructor( + private readonly youtubeService: YoutubeService, + ) {} + + // 获取AccessToken,并记录到用户,给平台回调用 + // @Get('auth/callback') + // async getAccessToken( + // @Query() + // query: { + // code: string + // state: string + // }, + // ) { + // // const { taskId, prefix } = JSON.parse(decodeURIComponent(query.state)); + // const stateData = JSON.parse(decodeURIComponent(query.state)) + // this.logger.log('stateData-----:', stateData) + // const taskId = stateData.originalState // Use originalState as taskId + // // const prefix = stateData.prefix + // const rcode = query.code + // // this.logger.log('taskId:--', taskId, rcode) + // const res = await this.youtubeService.setAccessToken( + // taskId, + // rcode, + // ) + // return res + // } + + // 获取页面的认证URL + // @NatsMessagePattern('plat.youtube.authUrl') + @Post('plat/youtube/authUrl') + async getAuthUrl(@Body() data: GetAuthUrlDto) { + const res = await this.youtubeService.getAuthUrl( + data.userId, + data.mail, + // data.type, + data.prefix, + data.spaceId, + ) + return res + } + + // 查询用户的认证信息 + // @NatsMessagePattern('plat.youtube.getAuthInfo') + @Post('plat/youtube/getAuthInfo') + async getAuthInfo(@Body() data: GetAuthInfoDto) { + this.logger.log('taskId--', data.taskId) + const res = await this.youtubeService.getAuthInfo(data.taskId) + return res + } + + // 查询账号的认证信息 + // @NatsMessagePattern('plat.youtube.getAccountAuthInfo') + @Post('plat/youtube/getAccountAuthInfo') + async getAccountAuthInfo(@Body() data: AccountIdDto) { + const res = await this.youtubeService.getUserAccessToken(data.accountId) + return res + } + + // 设置授权Token + // @NatsMessagePattern('plat.youtube.setAccessToken') + @Post('plat/youtube/setAccessToken') + async setAccessToken(@Body() data: CreateAccountAndSetAccessTokenDto) { + this.logger.log(`channel:--setAccessToken: ${data.taskId} , ${data.code}`) + const res = await this.youtubeService.setAccessToken(data.taskId, data.code) + return res + } + + // 创建账号并设置授权Token + // @NatsMessagePattern('plat.youtube.createAccountAndSetAccessToken') + @Post('plat/youtube/createAccountAndSetAccessToken') + async createAccountAndSetAccessToken( + @Body() data: CreateAccountAndSetAccessTokenDto, + ) { + const res = await this.youtubeService.createAccountAndSetAccessToken( + data.taskId, + { + code: data.code, + state: data.state, + }, + ) + return res + } + + // 查询账号是否授权 + // @NatsMessagePattern('plat.youtube.isAuthorized') + @Post('plat/youtube/isAuthorized') + async isAuthorized(@Body() data: AccountIdDto) { + const res = await this.youtubeService.isAuthorized(data.accountId) + return res + } + + // 刷新令牌token + @Post('auth/crawler/refresh-token/:accountId') + async PostRefreshToken( + @Param('accountId') accountId: string, + ) { + const res = this.youtubeService.getUserAccessToken(accountId) + return res + } + + // 刷新令牌token + // @NatsMessagePattern('plat.youtube.refreshToken') + @Post('plat/youtube/refreshToken') + async refreshToken(@Body() data: AccountIdDto) { + const res = await this.youtubeService.getUserAccessToken(data.accountId) + return res + } + + // 获取视频类别 + // @NatsMessagePattern('plat.youtube.getVideoCategories') + @Post('plat/youtube/getVideoCategories') + async getVideoCategories(@Body() data: VideoCategoriesDto) { + const res = await this.youtubeService.getVideoCategoriesList( + data.accountId, + data?.id, + data?.regionCode, + ) + return res + } + + // 获取视频列表 + // @NatsMessagePattern('plat.youtube.getVideosList') + @Post('plat/youtube/getVideosList') + getVideosList(@Body() data: VideosListDto) { + const res = this.youtubeService.getVideosList( + data.accountId, + data?.chart, + data?.id?.split(','), + data?.myRating, + data?.maxResults, + data?.pageToken, + ) + return res + } + + // 视频上传(20M以下小视频) + // @NatsMessagePattern('plat.youtube.uploadVideo') + @Post('plat/youtube/uploadVideo') + async uploadVideo(@Body() data: UploadVideoDto) { + this.logger.log('接收到上传视频请求:', data.accountId, data.fileName) + + const res = this.youtubeService.uploadVideo( + data.accountId, + data.fileBuffer, + data.fileName, + data.title, + data.description, + data.privacyStatus, + data.keywords, + data.categoryId, + // data.publishAt, + ) + return res + } + + // 初始化分片上传会话 + // @NatsMessagePattern('plat.youtube.initVideoUpload') + @Post('plat/youtube/initVideoUpload') + async initVideoUpload(@Body() data: InitUploadVideoDto) { + this.logger.log('接收到初始化视频上传请求:', { + accountId: data.accountId, + title: data.title, + contentLength: data.contentLength, + }) + const res = await this.youtubeService.initVideoUpload( + data.accountId, + data.title, + data.description || '', + data.tag ? data.tag.split(',') : [], + data.license, + data.categoryId || '22', + data.privacyStatus || 'public', + data.notifySubscribers || false, + data.embeddable || false, + data.selfDeclaredMadeForKids || false, + data.contentLength, + ) + return res + } + + // 上传视频分片 + // @NatsMessagePattern('plat.youtube.uploadVideoPart') + @Post('plat/youtube/uploadVideoPart') + async uploadVideoPart(@Body() data: UploadVideoPartDto) { + try { + this.logger.log('接收到上传视频分片请求:', { + accountId: data.accountId, + partNumber: data.partNumber, + hasFileBase64: !!data.fileBase64, + fileBase64Length: data.fileBase64 ? data.fileBase64.length : 0, + }) + + // 处理文件数据 - 解码Base64字符串 + if (!data.fileBase64) { + this.logger.error('文件数据未传输成功,收到undefined') + return false + } + + // 将Base64字符串转换为Buffer + const fileBuffer = Buffer.from(data.fileBase64, 'base64') + + // DTO已经通过@Type(() => Number)处理了类型转换 + this.logger.log( + `处理分片 ${data.partNumber}, Base64长度: ${data.fileBase64.length}, 解码后大小: ${fileBuffer.length} 字节`, + ) + + const res = await this.youtubeService.uploadVideoPart( + data.accountId, + fileBuffer, + data.uploadToken, + data.partNumber, + ) + + return res + } + catch (error) { + this.logger.error('处理视频分片失败:', error) + return error + } + } + + // 视频分片合并 + // @NatsMessagePattern('plat.youtube.videoComplete') + @Post('plat/youtube/videoComplete') + async videoComplete(@Body() data: VideoCompleteDto) { + try { + this.logger.log('接收到视频完成请求:', { + accountId: data.accountId, + totalSize: data.totalSize, + }) + + const res = await this.youtubeService.videoComplete( + data.accountId, + data.uploadToken, + data.totalSize, + ) + + return res + } + catch (error) { + this.logger.error('完成视频上传失败:', error) + return error + } + } + + // 创建顶级评论(评论会话) + // @NatsMessagePattern('plat.youtube.insertCommentThreads') + @Post('plat/youtube/insertCommentThreads') + async createTopComment(@Body() data: InsertCommentThreadsDto) { + try { + const res = await this.youtubeService.insertCommentThreads( + data.accountId, + data.channelId, + data.videoId, + data.textOriginal, + ) + + return res + } + catch (error) { + this.logger.error('创建顶级评论失败:', error) + return error + } + } + + // 获取评论会话列表 + // @NatsMessagePattern('plat.youtube.getCommentThreadsList') + @Post('plat/youtube/getCommentThreadsList') + async getCommentThreadsList(@Body() data: GetCommentThreadsListDto) { + try { + const res = await this.youtubeService.getCommentThreadsList( + data.accountId, + data?.allThreadsRelatedToChannelId, + data?.id?.split(',') || [], + data?.videoId, + data?.maxResults, + data?.pageToken, + data?.order, + data?.searchTerms, + ) + + return res + } + catch (error) { + this.logger.error('获取评论会话列表失败:', error) + return error + } + } + + // 获取子评论列表 + // @NatsMessagePattern('plat.youtube.getCommentsList') + @Post('plat/youtube/getCommentsList') + async getCommentsList(@Body() data: GetCommentsListDto) { + try { + const res = await this.youtubeService.getCommentsList( + data.accountId, + data?.parentId, + data?.id.split(','), + data?.maxResults || 5, + data?.pageToken, + ) + + return res + } + catch (error) { + this.logger.error('获取评论列表失败:', error) + return error + } + } + + // 创建二级评论 + // @NatsMessagePattern('plat.youtube.insertComment') + @Post('plat/youtube/insertComment') + async createSubComment(@Body() data: InsertCommentDto) { + try { + const res = await this.youtubeService.insertComment( + data.accountId, + data.parentId, + data.textOriginal, + ) + return res + } + catch (error) { + this.logger.error('创建二级评论失败:', error) + return error + } + } + + // 更新评论 + // @NatsMessagePattern('plat.youtube.updateComment') + @Post('plat/youtube/updateComment') + async updateComment(@Body() data: UpdateCommentDto) { + try { + const res = await this.youtubeService.updateComment( + data.accountId, + data.id, + data.textOriginal, + ) + return res + } + catch (error) { + this.logger.error('更新评论失败:', error) + return error + } + } + + // 设置一条或多条评论的审核状态。 + // @NatsMessagePattern('plat.youtube.setModerationStatusComments') + @Post('plat/youtube/setModerationStatusComments') + async setModerationStatusComments(@Body() data: SetCommentThreadsModerationStatusDto) { + try { + const res = await this.youtubeService.setModerationStatusComments( + data.accountId, + data.id.split(','), + data.moderationStatus, + data?.banAuthor || false, + ) + return res + } + catch (error) { + this.logger.error('设置评论审核状态失败:', error) + return error + } + } + + // 删除评论 + // @NatsMessagePattern('plat.youtube.deleteComment') + @Post('plat/youtube/deleteComment') + async deleteComment(@Body() data: DeleteCommentDto) { + try { + const res = await this.youtubeService.deleteComment( + data.accountId, + data.id, + ) + return res + } + catch (error) { + this.logger.error('删除评论失败:', error) + return error + } + } + + // 对视频的点赞、踩 + // @NatsMessagePattern('plat.youtube.setVideoRate') + @Post('plat/youtube/setVideoRate') + async rateVideo(@Body() data: VideoRateDto) { + try { + const res = await this.youtubeService.setVideosRate( + data.accountId, + data.id, + data.rating, + ) + return res + } + catch (error) { + this.logger.error('对视频的点赞、踩失败:', error) + return error + } + } + + // 获取视频的点赞、踩 + // @NatsMessagePattern('plat.youtube.getVideoRate') + @Post('plat/youtube/getVideoRate') + async getVideoRate(@Body() data: GetVideoRateDto) { + try { + const res = await this.youtubeService.getVideosRating( + data.accountId, + data.id.split(','), + ) + return res + } + catch (error) { + this.logger.error('获取视频的点赞、踩失败:', error) + return error + } + } + + // 删除视频 + // @NatsMessagePattern('plat.youtube.deleteVideo') + @Post('plat/youtube/deleteVideo') + async deleteVideo(@Body() data: DeleteVideoDto) { + try { + const res = await this.youtubeService.deleteVideo( + data.accountId, + data.id, + ) + return res + } + catch (error) { + this.logger.error('删除视频失败:', error) + return error + } + } + + // 更新视频 + // @NatsMessagePattern('plat.youtube.updateVideo') + @Post('plat/youtube/updateVideo') + async updateVideo(@Body() data: UpdateVideoDto) { + try { + const snippet = { + title: data.title, + categoryId: data.categoryId, + description: data?.description, + tags: data?.tags?.split(','), + defaultLanguage: data?.defaultLanguage, + + } + + const recordingDetails = { + recordingDate: data?.recordingDate, + } + + const status = { + privacyStatus: data?.privacyStatus, + publishAt: data?.publishAt, + } + + const res = await this.youtubeService.updateVideo( + data.accountId, + data.id, + snippet, + status, + recordingDetails, + ) + return res + } + catch (error) { + this.logger.error('更新视频失败:', error) + return error + } + } + + // 创建播放列表 + // @NatsMessagePattern('plat.youtube.insertPlayList') + @Post('plat/youtube/insertPlayList') + async insertPlayList(@Body() data: InsertPlayListDto) { + try { + const snippet = { + title: data.title, + description: data?.description, + } + + const status = { + privacyStatus: data?.privacyStatus, + } + + const res = await this.youtubeService.insertPlayList( + data.accountId, + snippet, + status, + ) + return res + } + catch (error) { + this.logger.error('插入播放列表失败:', error) + return error + } + } + + // 获取播放列表 + // @NatsMessagePattern('plat.youtube.getPlayList') + @Post('plat/youtube/getPlayList') + async getPlayList(@Body() data: GetPlayListDto) { + try { + const res = await this.youtubeService.getPlayList( + data.accountId, + data.channelId, + data.id, + data.mine, + data.maxResults, + data.pageToken, + ) + return res + } + catch (error) { + this.logger.error('获取播放列表失败:', error) + return error + } + } + + // 更新播放列表 + // @NatsMessagePattern('plat.youtube.updatePlayList') + @Post('plat/youtube/updatePlayList') + async updatePlayList(@Body() data: UpdatePlayListDto) { + try { + const res = await this.youtubeService.updatePlayList( + data.accountId, + data.id, + data.title, + data.description, + data.privacyStatus, + data.podcastStatus, + ) + return res + } + catch (error) { + this.logger.error('更新播放列表项失败:', error) + return error + } + } + + // 删除播放列表 + // @NatsMessagePattern('plat.youtube.deletePlayList') + @Post('plat/youtube/deletePlayList') + async deletePlayList(@Body() data: DeletePlayListDto) { + try { + const res = await this.youtubeService.deletePlaylist( + data.accountId, + data.id, + ) + return res + } + catch (error) { + this.logger.error('删除播放列表失败:', error) + return error + } + } + + // 将视频添加到播放列表中 + // @NatsMessagePattern('plat.youtube.addVideoToPlaylist') + @Post('plat/youtube/addVideoToPlaylist') + async addVideoToPlaylist(@Body() data: InsertPlayItemsDto) { + const snippet = { + playlistId: data.playlistId, + resourceId: data.resourceId, + position: data?.position, + } + + const contentDetails = { + note: data?.note, + startAt: data?.startAt, + endAt: data?.endAt, + } + + try { + const res = await this.youtubeService.addVideoToPlaylist( + data.accountId, + snippet, + contentDetails, + ) + return res + } + catch (error) { + this.logger.error('将视频添加到播放列表中失败:', error) + return error + } + } + + // 获取播放列表项 + // @NatsMessagePattern('plat.youtube.getPlayItems') + @Post('plat/youtube/getPlayItems') + async getPlayItems(@Body() data: GetPlayItemsDto) { + try { + const res = await this.youtubeService.getPlayItemsList( + data.accountId, + data.id, + data.playlistId, + data.maxResults, + data.pageToken, + data.videoId, + ) + return res + } + catch (error) { + this.logger.error('获取播放列表项失败:', error) + return error + } + } + + // 插入播放列表项 + // @NatsMessagePattern('plat.youtube.insertPlayItems') + @Post('plat/youtube/insertPlayItems') + async insertPlayItems(@Body() data: InsertPlayItemsDto) { + try { + const res = await this.youtubeService.insertPlayItems( + data.accountId, + data.playlistId, + data.resourceId, + data.position, + data.note, + ) + return res + } + catch (error) { + this.logger.error('插入播放列表项失败:', error) + return error + } + } + + // 更新播放列表项 + // @NatsMessagePattern('plat.youtube.updatePlayItems') + @Post('plat/youtube/updatePlayItems') + async updatePlayItems(@Body() data: UpdatePlayItemsDto) { + try { + const snippet = { + playlistId: data.playlistId, + resourceId: data.resourceId, + position: data?.position, + } + + const contentDetails = { + note: data?.note, + startAt: data?.startAt, + endAt: data?.endAt, + } + + const res = await this.youtubeService.updatePlayItems( + data.accountId, + data.id, + snippet, + contentDetails, + ) + return res + } + catch (error) { + this.logger.error('更新播放列表项失败:', error) + return error + } + } + + // 删除播放列表项 + // @NatsMessagePattern('plat.youtube.deletePlayItems') + @Post('plat/youtube/deletePlayItems') + async deletePlayItems(@Body() data: DeletePlayItemsDto) { + try { + const res = await this.youtubeService.deletePlayItems( + data.accountId, + data.id, + ) + return res + } + catch (error) { + this.logger.error('删除播放列表项失败:', error) + return error + } + } + + // 获取频道列表 + // @NatsMessagePattern('plat.youtube.getChannelsList') + @Post('plat/youtube/getChannelsList') + async getChannelsList(@Body() data: GetChannelsListDto) { + try { + const res = await this.youtubeService.getChannelsList( + data.accountId, + data?.forHandle || '', + data?.forUsername || '', + data?.id?.split(',') || [], + data?.mine || false, + data?.maxResults || 5, + data?.pageToken || '', + ) + + return res + } + catch (error) { + this.logger.error(`get channel list failed: ${error}`) + return error + } + } + + // 更新频道 + // // @NatsMessagePattern('plat.youtube.updateChannels') + @Post('plat/youtube/updateChannels') + // async updateChannels(@Body() data: UpdateChannelsDto) { + // try { + // const res = await this.youtubeService.updateChannels( + // data.accountId, + // data.id, + // data.brandingSettings.channel.country, + // data.contentDetails, + // data.innertubeMetadata, + // data.localizations, + // data.statistics, + // data.status, + // data.topicDetails, + // data.contentOwnerDetails, + // data.defaultLanguage, + // data.defaultAudioLanguage, + // data.description, + // data.keywords, + // data.privacyStatus, + // data.publishedAt, + // data.signatureTimestamp, + // data.signatureVersion, + // data.signaturePublicKey, + // data.signature, + // ) + // return res + // } + // catch (error) { + // this.logger.error('更新频道失败:', error) + // return error + // } + // } + + // // 创建频道板块 + // // @NatsMessagePattern('plat.youtube.insertChannelsSections') + @Post('plat/youtube/insertChannelsSections') + // async insertChannelsSections(@Body() data: InsertChannelsSectionsDto) { + // try { + // const res = await this.youtubeService.insertChannelsSections( + // data.accountId, + // data.channelId, + // data.title, + // data.position, + // data.customUrl, + // data.defaultLanguage, + // data.defaultAudioLanguage, + // data.description, + // data.keywords, + // data.privacyStatus, + // data.publishedAt, + // data.signatureTimestamp, + // data.signatureVersion, + // data.signaturePublicKey, + // data.signature, + // ) + // return res + // } + // catch (error) { + // this.logger.error('创建频道板块失败:', error) + // return error + // } + // } + + // 获取频道板块列表 + // @NatsMessagePattern('plat.youtube.getChannelsSectionsList') + @Post('plat/youtube/getChannelsSectionsList') + async getChannelsSectionsList(@Body() data: ChannelsSectionsListDto) { + try { + const res = await this.youtubeService.getChannelSectionsList( + data.accountId, + data?.channelId, + data?.id?.split(','), + data?.mine, + ) + return res + } + catch (error) { + this.logger.error('获取频道板块列表失败:', error) + return error + } + } + + // // 更新频道板块 + // // @NatsMessagePattern('plat.youtube.updateChannelsSections') + @Post('plat/youtube/updateChannelsSections') + // async updateChannelsSections(@Body() data: UpdateChannelsSectionsDto) { + // try { + // const res = await this.youtubeService.updateChannelsSections( + // data.accountId, + // data.id, + // data.channelId, + // data.title, + // data.position, + // data.customUrl, + // data.defaultLanguage, + // data.defaultAudioLanguage, + // data.description, + // data.keywords, + // data.privacyStatus, + // data.publishedAt, + // data.signatureTimestamp, + // data.signatureVersion, + // data.signaturePublicKey, + // data.signature, + // ) + // return res + // } + // catch (error) { + // this.logger.error('更新频道板块失败:', error) + // return error + // } + // } + + // // 删除频道板块 + // // @NatsMessagePattern('plat.youtube.deleteChannelsSections') + @Post('plat/youtube/deleteChannelsSections') + // async deleteChannelsSections(@Body() data: DeleteChannelsSectionsDto) { + // try { + // const res = await this.youtubeService.deleteChannelsSections( + // data.accountId, + // data.id, + // ) + // return res + // } + // catch (error) { + // this.logger.error('删除频道板块失败:', error) + // return error + // } + // } + + /** + * YouTube搜索接口 + * 支持多种搜索条件和排序方式 + */ + // @NatsMessagePattern('plat.youtube.search') + @Post('plat/youtube/search') + async search(@Body() data: SearchDto) { + try { + // 处理日期参数 + let publishedBefore: Date | undefined + let publishedAfter: Date | undefined + + if (data.publishedBefore) { + publishedBefore = new Date(data.publishedBefore) + } + if (data.publishedAfter) { + publishedAfter = new Date(data.publishedAfter) + } + + const res = await this.youtubeService.getSearchList( + data.accountId, + data.forMine, + data.maxResults, + data.order, + data.pageToken, + publishedBefore, + publishedAfter, + data.q, + data.type, + data.videoCategoryId, + ) + return res + } + catch (error) { + this.logger.error('YouTube搜索失败:', error) + return error + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.module.ts new file mode 100644 index 000000000..1128f2b16 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.module.ts @@ -0,0 +1,27 @@ +/* + * @Author: zhangwei + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-06-09 16:11:36 + * @LastEditors: zhangwei + * @Description: youtube + */ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { OAuth2Crendential, OAuth2CrendentialSchema } from '../../../libs/database/schema/oauth2Crendential.schema' +import { YoutubeApiModule } from '../../../libs/youtube/youtubeApi.module' +import { YoutubeController } from './youtube.controller' +import { YoutubeService } from './youtube.service' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: OAuth2Crendential.name, schema: OAuth2CrendentialSchema }, + ]), + + YoutubeApiModule, + ], + controllers: [YoutubeController], + providers: [YoutubeService], + exports: [YoutubeService], +}) +export class YoutubeModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.service.ts new file mode 100644 index 000000000..442343b7a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/plat/youtube/youtube.service.ts @@ -0,0 +1,2163 @@ +import { Readable } from 'node:stream' +/* + * @Author: zhangwei + * @Date: 2025-05-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: zhangwei + * @Description: youtube + */ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' + +import { AccountStatus, AccountType, AitoearnServerClientService, NewAccount } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import axios from 'axios' +import { Auth, google } from 'googleapis' +import { Model } from 'mongoose' +import { v4 as uuidv4 } from 'uuid' +import { getCurrentTimestamp } from '../../../common' +import { ExceptionCode } from '../../../common/enums/exception-code.enum' +import { config } from '../../../config' +import { AccountService } from '../../../core/account/account.service' +import { Account } from '../../../libs/database/schema/account.schema' +import { + OAuth2Crendential, + TokenStatus, +} from '../../../libs/database/schema/oauth2Crendential.schema' +import { YoutubeApiService } from '../../../libs/youtube/youtubeApi.service' + +interface AuthTaskInfo { + state: string + userId: string + mail: string + status: 0 | 1 + accountId?: string + avatar?: string + nickname?: string + uid?: string + type?: string + account?: string + spaceId?: string +} + +@Injectable() +export class YoutubeService { + private youtubeClient = google.youtube('v3') + private webClientSecret: string + private webClientId: string + private webRenderBaseUrl: string + private oauth2Client: Auth.OAuth2Client + private readonly platform = AccountType.YOUTUBE + private readonly logger = new Logger(YoutubeService.name) + + constructor( + private readonly redisService: RedisService, + private readonly youtubeApiService: YoutubeApiService, + private readonly accountService: AccountService, + private readonly serverClient: AitoearnServerClientService, + + @InjectModel(OAuth2Crendential.name) + private OAuth2CrendentialModel: Model, + ) { + this.webClientSecret = config.youtube.secret + this.webClientId = config.youtube.id + this.webRenderBaseUrl = config.youtube.authBackHost + this.oauth2Client = new google.auth.OAuth2() + } + + initializeYouTubeClient(accessToken: string) { + this.oauth2Client.setCredentials({ access_token: accessToken }) + this.youtubeClient = google.youtube({ version: 'v3', auth: this.oauth2Client }) + } + + /** + * 创建账号+设置授权Token + * @param taskId + * @param data + * @returns + */ + async createAccountAndSetAccessToken( + taskId: string, + data: { code: string, state: string }, + ) { + const { code } = data + + const authTask = await this.redisService.getJson( + `youtube:authTask:${taskId}`, + ) + + if (!authTask || authTask?.state !== taskId) { + this.logger.error(`无效的任务ID: ${taskId}`) + return { status: 0, message: '无效的任务ID' } + } + try { + // 使用授权码获取访问令牌和刷新令牌 + const params = new URLSearchParams({ + code, + redirect_uri: `${this.webRenderBaseUrl}`, + client_id: this.webClientId, + grant_type: 'authorization_code', + client_secret: this.webClientSecret, + }) + + const response = await axios.post( + 'https://oauth2.googleapis.com/token', + params.toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ) + const { access_token, refresh_token, expires_in, id_token } + = response.data + + // 验证ID令牌以获取用户信息 + // const oauth2Client = new google.auth.OAuth2(); + // this.oauth2Client.setCredentials({ access_token }); + this.initializeYouTubeClient(access_token) + const ticket = await this.oauth2Client.verifyIdToken({ + idToken: id_token, + audience: this.webClientId, + }) + + const payload = ticket.getPayload() + if (!payload) { + throw new Error('Invalid ID token') + } + const googleId = payload.sub + const email = payload.email || '' + const userId = authTask.userId + // console.log('-----userId:----', userId) + // 获取YouTube频道信息,用于更新账号数据库 + const accountInfo = await this.updateYouTubeAccountInfo( + userId, + email, + googleId, + access_token, + refresh_token, + expires_in, + authTask.spaceId, + ) + + // 缓存令牌 + let res = await this.redisService.setJson( + `youtube:accessToken:${accountInfo.id}`, + { + access_token, + refresh_token, + expiresAt: getCurrentTimestamp() + expires_in, + }, + expires_in, + ) + + // 查询AccountToken数据库里是否存在令牌,如果存在,且上面获得refresh_token 存在,且不为空或null,则更新 + // 如果不存在,则创建 + let accountToken = await this.OAuth2CrendentialModel.findOne({ + platform: AccountType.YOUTUBE, + accountId: googleId, + }) + + if (accountToken) { + // 更新现有令牌 + if (refresh_token && refresh_token.trim() !== '') { + accountToken.refreshToken = refresh_token + } + + accountToken.accessTokenExpiresAt = getCurrentTimestamp() + expires_in + accountToken.updatedAt = new Date() + await accountToken.save() + } + else { + // 创建新的令牌记录 + accountToken = await this.OAuth2CrendentialModel.create({ + platform: AccountType.YOUTUBE, + accountId: googleId, + refreshToken: refresh_token, + status: TokenStatus.NORMAL, + createTime: new Date(), + updateTime: new Date(), + accessTokenExpiresAt: getCurrentTimestamp() + expires_in, + }) + } + + // 更新任务信息 + authTask.status = 1 + authTask.accountId = accountInfo.id + authTask.mail = email || '' + res = await this.redisService.setJson( + `youtube:authTask:${taskId}`, + authTask, + 60 * 3, + ) + + // // 返回系统令牌 + this.logger.log('最终返回', res) + if (!res) + return { status: 0, message: '更新任务信息失败' } + return { status: 1, message: '添加账号成功', accountId: accountInfo.id } + + // return results; + } + catch (error) { + this.logger.log('处理授权码失败:', error) + return { status: 0, message: `授权失败: ${error.message}` } + } + } + + private async saveOAuthCredential(accountId: string, accessTokenInfo: any) { + accessTokenInfo.expires_in = getCurrentTimestamp() + accessTokenInfo.expires_in + const cached = await this.redisService.setJson( + `youtube:accessToken:${accountId}`, + accessTokenInfo, + ) + const persistResult = await this.OAuth2CrendentialModel.updateOne({ + accountId, + platform: this.platform, + }, { + accessToken: accessTokenInfo.access_token, + refreshToken: accessTokenInfo.refresh_token, + accessTokenExpiresAt: accessTokenInfo.expires_in, + }, { + upsert: true, + }) + const saved = cached && (persistResult.modifiedCount > 0 || persistResult.upsertedCount > 0) + return saved + } + + private async getOAuth2Credential(accountId: string): Promise { + let credential = await this.redisService.getJson( + `youtube:accessToken:${accountId}`, + ) + this.logger.log(`getOAuth2Credential from redis: ${JSON.stringify(credential)}`) + if (!credential || !credential.refresh_token) { + const oauth2Credential = await this.OAuth2CrendentialModel.findOne({ + accountId, + platform: this.platform, + }) + this.logger.log(`getOAuth2Credential from db: ${JSON.stringify(oauth2Credential)}`) + if (!oauth2Credential) { + return null + } + credential = { + access_token: oauth2Credential.accessToken, + refresh_token: oauth2Credential.refreshToken, + expires_in: oauth2Credential.accessTokenExpiresAt, + refresh_expires_in: oauth2Credential.refreshTokenExpiresAt || 0, + } + } + return credential + } + + /** + * 设置授权Token + * @param taskId + * @param data + * @returns + */ + async setAccessToken(taskId: string, code: string) { + const authTaskState = await this.redisService.getJson( + `youtube:authTask:${taskId}`, + ) + if (!authTaskState || authTaskState.state !== taskId) + return null + + try { + // 使用授权码获取访问令牌和刷新令牌 + const params = new URLSearchParams({ + code, + redirect_uri: `${this.webRenderBaseUrl}`, + client_id: this.webClientId, + grant_type: 'authorization_code', + client_secret: this.webClientSecret, + }) + + const response = await axios.post( + 'https://oauth2.googleapis.com/token', + params.toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ) + + const accessTokenInfo = response.data + const access_token = accessTokenInfo.access_token + const refresh_token = accessTokenInfo.refresh_token + const expires_in = accessTokenInfo.expires_in + const id_token = accessTokenInfo.id_token + this.logger.log(`Youtube OAuth2 response: ${JSON.stringify(response.data)}`) + // 验证ID令牌以获取用户信息 + this.initializeYouTubeClient(access_token) + const ticket = await this.oauth2Client.verifyIdToken({ + idToken: id_token, + audience: this.webClientId, + }) + + const payload = ticket.getPayload() + if (!payload) { + throw new Error('Invalid ID token') + } + const googleId = payload.sub + const email = payload.email || '' + const userId = authTaskState.userId + // 获取YouTube频道信息,用于更新账号数据库 + const accountInfo = await this.updateYouTubeAccountInfo( + userId, + email, + googleId, + access_token, + refresh_token, + expires_in, + authTaskState.spaceId, + ) + + // 缓存令牌 + await this.saveOAuthCredential(accountInfo.id, accessTokenInfo) + + // 更新任务信息 + authTaskState.status = 1 + authTaskState.accountId = accountInfo.id + authTaskState.avatar = accountInfo.avatar + authTaskState.nickname = accountInfo.nickname + authTaskState.mail = email + authTaskState.uid = googleId + authTaskState.type = 'youtube' + authTaskState.account = accountInfo.id + await this.redisService.setJson( + `youtube:authTask:${taskId}`, + authTaskState, + 60 * 3, + ) + + return { + status: 1, + message: '授权成功', + accountId: accountInfo.id, + } + } + catch (error) { + this.logger.error(`处理授权码失败: ${error}`) + return { + status: 0, + message: '处理授权码失败', + accountId: error, + } + } + } + + /** + * 获取YouTube频道信息并更新账号数据库 + * @param userId 用户ID + * @param googleId Google ID + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + */ + private async updateYouTubeAccountInfo( + userId: string, + email: string, + googleId: string, + accessToken: string, + refreshToken: string, + expires_in: number, + spaceId?: string, + ): Promise { + try { + let newAccount = email + let newNickname = '' + let newAvatar = '' + let channelId = '' + + // 获取当前用户的YouTube频道信息 + const response = await this.youtubeClient.channels.list({ + part: ['snippet', 'statistics'], + mine: true, + }) + + if (!response.data.items || response.data.items.length === 0) { + this.logger.log(`无法获取YouTube频道信息,将从Google用户信息获取`) + try { + const responseGoogle = await axios.get( + 'https://www.googleapis.com/oauth2/v3/userinfo', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ) + + const userInfoData = responseGoogle.data + + // 使用Google用户信息更新账号数据 + newAccount = googleId || userInfoData.email + newNickname = userInfoData.name || '' + newAvatar = userInfoData.picture || '' + this.logger.log('成功获取Google用户信息:', userInfoData) + } + catch (error) { + this.logger.error('获取Google用户信息失败:', error) + newAccount = googleId + newNickname = 'YouTube User' + } + } + else { + const channel = response.data.items[0] + channelId = channel.id || '' + newAccount = channel.id || '' + newNickname = channel.snippet?.title || '' + newAvatar = channel.snippet?.thumbnails?.default?.url || '' + this.logger.log(`成功获取YouTube频道信息: ${channel.snippet?.title}`) + } + + const channelInfo = new NewAccount({ + userId, + type: AccountType.YOUTUBE, + uid: googleId, + channelId, + account: newAccount, + nickname: newNickname, + avatar: newAvatar, + refresh_token: refreshToken, + groupId: spaceId, + status: AccountStatus.NORMAL, + loginTime: new Date(), + lastStatsTime: new Date(), + }) + const accountInfo = await this.accountService.createAccount( + userId, + { + type: AccountType.YOUTUBE, + uid: googleId, + }, + channelInfo, + ) + + if (!accountInfo) + throw new Error('创建账号失败') + + return accountInfo + } + catch (error) { + this.logger.error('更新YouTube账号信息失败:', error, error.stack) + throw new Error('更新YouTube账号信息失败') + } + } + + /** + * 刷新AccessToken + * @param accountId + * @param refreshToken + * @returns + */ + async refreshAccessToken( + accountId: string, + refreshToken: string, + ): Promise { + // console.log(accountId, refreshToken) + const accessTokenInfo + = await this.youtubeApiService.refreshAccessToken(refreshToken) + if (!accessTokenInfo) + return '' + + // youtube did not return refresh_token again, so we need to keep the old one + if (!accessTokenInfo.refresh_token) { + accessTokenInfo.refresh_token = refreshToken + } + const res = await this.saveOAuthCredential(accountId, accessTokenInfo) + if (!res) + return '' + + return accessTokenInfo.access_token + } + + /** + * 获取用户的Youtube访问令牌 + * @param accountId 账号ID + * @returns 访问令牌 + */ + async getUserAccessToken(accountId: string) { + this.logger.log(`getUserAccessToken accountId: ${accountId}`) + + const credential = await this.getOAuth2Credential(accountId) + if (!credential || !credential.access_token) { + this.logger.error(`youtube credential not found for accountId: ${accountId}`) + throw new AppException(ExceptionCode.Failed, `youtube credential not found for accountId: ${accountId}`) + } + + const isTokenExpired = credential.expires_in <= getCurrentTimestamp() + if (!isTokenExpired) { + return credential.access_token as string + } + + if (!credential.refresh_token) { + this.logger.error(`refresh Token not found for accountId: ${accountId}`) + throw new AppException(ExceptionCode.Failed, `refresh Token not found for accountId: ${accountId}`) + } + + const isRefreshTokenExpired = credential.refresh_expires_in && credential.refresh_expires_in <= getCurrentTimestamp() + if (isRefreshTokenExpired) { + const errMsg = `youtube refresh Token expired for accountId: ${accountId}, expired at: ${credential.refresh_expires_in}, please re-authorize` + this.logger.error(errMsg) + throw new AppException(ExceptionCode.Failed, errMsg) + } + + // 刷新并获取新令牌 + const accessToken = await this.refreshAccessToken( + accountId, + credential.refresh_token, + ) + + if (!accessToken) { + this.logger.error(`refresh Token failed for accountId: ${accountId}`) + throw new AppException(ExceptionCode.Failed, `refresh Token failed for accountId: ${accountId}`) + } + + return accessToken + } + + /** + * 检查用户是否已授权YouTube + * @param accountId 账号ID + * @returns 是否已授权 + */ + async isAuthorized(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId) + return !!accessToken + } + catch (error) { + return error + } + } + + /** + * 获取授权URL + * @param userId + * @param mail + * @param type + * @param prefix + * @returns + */ + async getAuthUrl( + userId: string, + mail: string, + // type: 'h5' | 'pc', + prefix?: string, + spaceId?: string, + ) { + const state = uuidv4() + // 指定YouTube特定的scope + const youtubeScopes = [ + 'https://www.googleapis.com/auth/youtube.force-ssl', + 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.upload', + 'https://www.googleapis.com/auth/userinfo.profile', + ] + + const stateData = { + originalState: state, // 保留原始state值 + userId, // 添加token + email: mail, + prefix, + spaceId, + } + + // 将状态数据转换为JSON字符串并编码 + const encodedState = encodeURIComponent(JSON.stringify(stateData)) + + const params = new URLSearchParams({ + scope: youtubeScopes.join(' '), + access_type: 'offline', + include_granted_scopes: 'true', + response_type: 'code', + state: encodedState, + redirect_uri: `${this.webRenderBaseUrl}`, + client_id: this.webClientId, + prompt: 'consent', // 强制要求用户确认授权,以便我们能够获取refresh_token + // login_hint: userId, + }) + + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth') + authUrl.search = params.toString() + const rRes = await this.redisService.setJson( + `youtube:authTask:${state}`, + { state, status: 0, userId, mail, spaceId }, + 60 * 5, + ) + this.logger.log(`youtubeService getAuthUrl rRes: ${rRes}`) + return rRes + ? { + url: authUrl.toString(), + state, + taskId: state, + } + : null + } + + /** + * 获取用户的授权信息 + * @param userId + * @returns + */ + async getAuthInfo(taskId: string) { + const data = await this.redisService.getJson<{ + state: string + status: number + accountId?: string + }>(`youtube:authTask:${taskId}`) + return data + } + + /** + * 获取视频类别列表。 + */ + async getVideoCategoriesList( + accountId: string, + id?: string, + regionCode?: string, + ) { + this.logger.log(`youtubeService getVideoCategoriesList: ${accountId}, ${id}, ${regionCode}}`) + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + // throw new AppException(10010, '账号有误') + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + // return { code: ExceptionCode.Failed, message: 'refresh token error.', data: [] } + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + try { + // this.logger.log(requestBody) + const response = await this.youtubeClient.videoCategories.list({ + part: ['snippet'], + ...(id && { id: [id] }), + ...(regionCode && { regionCode }), + auth: this.oauth2Client, + }) + + // this.logger.log(response.data) + return response + } + catch (err) { + this.logger.error(`The API returned an error: ${err}`) + return err + } + } + + /** + * 获取视频列表。 + * @param id 视频ID + * @param chart 图表类型 + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 视频列表 + */ + async getVideosList( + accountId: string, + chart?: string, + id?: string[], + myRating?: boolean, + maxResults?: number, + pageToken?: string, + // params: GetVideosListParams, + ) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, + part: ['snippet', 'contentDetails', 'statistics', 'id', 'status', 'topicDetails'], + } + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id // 如果提供了 id, 使用 id + } + else if (chart) { + if (chart === 'mostPopular') { + requestParams.chart = chart + } + } + else if (myRating !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (myRating) { + requestParams.myRating = myRating // 请求当前登录用户的频道 + } + } + else if (maxResults) { + requestParams.maxResults = maxResults // 如果提供了 handle, 使用 handle + } + else if (pageToken) { + requestParams.pageToken = pageToken // 如果提供了 handle, 使用 handle + } + + this.logger.log(requestParams) + try { + const response = await this.youtubeClient.videos.list(requestParams) + // const response = await this.youtubeApiService.getVideosList(requestParams) + return response + } + catch (err) { + this.logger.error(`The API returned an error: ${err}`) + return err + } + } + + /** + * 上传视频(小于20M)。 + * @param file 视频文件 + * @param accountId 账号ID + * @param title 标题 + * @param description 描述 + * @param keywords 关键词 + * @param categoryId 分类ID + * @param privacyStatus 状态(公开?私密) + * @returns 视频ID + */ + async uploadVideo( + accountId: string, + fileBuffer: any, + fileName: string, + title: string, + description: string, + privacyStatus: string, + keywords?: string, + categoryId?: string, + // publishAt?: string, + ) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + try { + // 确保fileBuffer是Buffer实例 + let bufferInstance: Buffer + if (Buffer.isBuffer(fileBuffer)) { + bufferInstance = fileBuffer + } + else if ( + fileBuffer.type === 'Buffer' + && Array.isArray(fileBuffer.data) + ) { + // 从序列化的Buffer对象恢复 + bufferInstance = Buffer.from(fileBuffer.data) + } + else if (typeof fileBuffer === 'object') { + // 尝试从普通对象恢复为Buffer + bufferInstance = Buffer.from(Object.values(fileBuffer)) + } + else { + this.logger.log('无效的文件Buffer格式') + return '无效的文件Buffer格式' + } + this.logger.log('文件大小:', bufferInstance.length, '字节') + + // 客户端已经在 ensureValidAccessToken 中初始化了 + + try { + const channelInfo = await this.youtubeClient.channels.list({ + part: ['snippet'], + mine: true, + auth: this.oauth2Client, + }) + + if (!channelInfo.data.items || channelInfo.data.items.length === 0) { + this.logger.log('未检测到可用的 YouTube 频道,请先创建频道') + return '未检测到可用的 YouTube 频道,请先创建频道' + } + + // 可以上传 + } + catch (err) { + if (err.errors?.[0]?.reason === 'youtubeSignupRequired') { + // console.log('当前账号未启用 YouTube,请先创建频道'); + // throw new Error('当前账号未启用 YouTube,请先创建频道'); + return '当前账号未启用 YouTube,请先创建频道' + } + } + + // 准备视频的元数据 + const fileStream = Readable.from(bufferInstance) // 使用转换后的Buffer创建可读流 + const fileSize = bufferInstance.length // 获取文件大小 + + // 构造请求体 + const requestBody: any = { + snippet: { + title, + description, + // tags: keywords ? keywords.split(',') : [], + tags: keywords || [], + categoryId: categoryId || '22', // 默认 categoryId 为 '22',如果没有指定 + }, + status: { + privacyStatus, // 可以是 'public', 'private', 'unlisted' + }, + } + + // if (publishAt) { + // requestBody.status.publishAt = publishAt // 如果提供了 publishAt 则使用 publishAt + // } + this.logger.log(requestBody) + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.videos.insert( + { + auth: this.oauth2Client, + // part: 'snippet, status, id, contentDetails', + part: ['snippet', 'status', 'id', 'contentDetails'], + requestBody, + media: { + body: fileStream, // 上传的文件流 + }, + }, + { + onUploadProgress: (e) => { + const progress = Math.round((e.bytesRead / fileSize) * 100) + this.logger.log(`Uploading... ${progress}%`) + }, + }, + ) + + // 返回上传的视频 ID + if (response.data.id) { + this.logger.log('Video uploaded successfully, video ID:', response.data) + return response.data + } + else { + this.logger.error('Video upload failed') + return null + } + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 初始化分片上传会话 + * @param accountId 账户ID + * @param title 视频标题 + * @param description 视频描述 + * @param tags 视频标签 + * @param categoryId 视频分类 + * @param privacy 隐私设置 + // * @param publishAt 发布时间 + */ + async initVideoUpload( + accountId: string, + title: string, + description: string, + tags: string[], + licence = 'youtube', + categoryId = '22', + privacyStatus = 'private', + notifySubscribers = false, + embeddable = false, + selfDeclaredMadeForKids = false, + contentLength?: number, + ) { + const accessToken = await this.getUserAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + try { + // 准备视频元数据 + const requestBody: { + notifySubscribers: boolean + snippet: { + title: string + description: string + tags: string[] + categoryId: string + } + status: { + privacyStatus: string + selfDeclaredMadeForKids: boolean + licence: string + embeddable: boolean + } + } = { + notifySubscribers, + snippet: { + title, + description, + tags, + categoryId, + }, + status: { + privacyStatus, + selfDeclaredMadeForKids, + licence, + embeddable, + }, + } + + // if (publishAt) { + // requestBody.status.publishAt = publishAt + // } + + // 正确的 resumable upload 初始化 + const url = 'https://www.googleapis.com/upload/youtube/v3/videos' + + // 构建请求头 + const headers: any = { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'X-Upload-Content-Type': 'video/*', // 使用具体的内容类型 + } + + // 如果提供了内容长度,添加对应头部 + if (contentLength && contentLength > 0) { + headers['X-Upload-Content-Length'] = String(contentLength) + } + else { + // 使用默认值(例如100MB)以确保请求能够通过 + headers['X-Upload-Content-Length'] = String(500 * 1024 * 1024) // 100MB + } + + this.logger.log(`youtube upload init params: ${requestBody.snippet}`) + this.logger.log(`youtube video length: ${contentLength}`) + + const response = await axios({ + method: 'post', + url, + params: { + uploadType: 'resumable', + part: 'snippet,status,contentDetails', + }, + headers, + data: requestBody, + }) + + this.logger.log('初始化上传响应成功:', { + status: response.status, + headers: response.headers, + }) + + // 返回上传令牌,即 Location 头 + // return { + // uploadToken: response.headers.location, + // videoId: null, // 初始化阶段通常没有 videoId + // } + return response.headers.location + } + catch (error) { + this.logger.error(`Error initializing video upload: ${error.message}`) + return false + } + } + + /** + * 文件分片上传 + * @param accountId 账户ID + * @param file 分片数据 + * @param uploadToken 上传令牌 + * @param partNumber 分片序号 + */ + async uploadVideoPart( + accountId: string, + file: Buffer, + uploadToken: string, + partNumber: number, + ) { + const accessToken = await this.getUserAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + try { + const chunkSize = 5 * 1024 * 1024 // 每个分片1MB + // 计算当前分片的字节范围 parNumber 是从1开始 + const contentLength = file.length + const startByte = (partNumber - 1) * chunkSize + const endByte = startByte + contentLength - 1 + + this.logger.log(`Uploading part ${partNumber} with range: ${startByte}-${endByte}, contentLength: ${contentLength}`) + // 发送分片上传请求 + const response = await axios.put(uploadToken, file, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/octet-stream', + 'Content-Length': contentLength.toString(), + 'Content-Range': `bytes ${startByte}-${endByte}/*`, // 表示分片范围,*表示文件总大小未知 + }, + }) + // 将headers转换为普通对象,避免返回RawAxiosHeaders类型 + const plainHeaders = response.headers ? { ...response.headers } : {} + this.logger.log('分片上传响应成功:', { + status: response.status, + headers: plainHeaders, + }) + + if (response.status === 200) { + this.logger.log('分片上传完成') + + return response.status + } + } + catch (error) { + this.logger.log('Error uploading video part:', error.response.status, error.response.data) + // console.error('Error uploading video part:', error.response); + if (error.response && error.response.status === 308) { + this.logger.log('分片上传成功') + + return error.response.status + } + // throw new OptRpcException(50001, `上传视频分片失败: ${error.message}`); + return `上传视频分片失败: ${error.message}` + } + } + + /** + * 完成视频上传 + * @param accountId 账户ID + * @param uploadToken 上传令牌 + * @param totalSize 视频文件的总大小(字节) + */ + async videoComplete( + accountId: string, + uploadToken: string, + totalSize: number, + ) { + const accessToken = await this.getUserAccessToken(accountId) + if (!accessToken) + throw new AppException(10010, '账号有误') + + try { + // 对于YouTube,通常在最后一个分片上传完成后,上传就自动完成了 + // 但我们可以发送一个空的PUT请求,确认上传已完成 + const response = await axios.put(uploadToken, '', { + headers: { + 'Content-Length': '0', + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/octet-stream', + 'Content-Range': `bytes */${totalSize}`, // 添加必要的Content-Range头部 + }, + }) + + return response.data.id + } + catch (error) { + this.logger.log('Error completing video upload:', error) + // return `完成视频上传失败: ${error.message}` + return false + } + } + + /** + * 获取子评论列表。 + * @param parentId 父评论ID + * @param id 评论ID + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 评论列表 + */ + async getCommentsList( + accountId: string, + parentId?: string, + id?: string[], + maxResults?: number, + pageToken?: string, + ) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, // 使用授权的 access token + part: ['id', 'snippet'], + ...(id && { id }), + ...(parentId && { id }), + ...(maxResults && { maxResults }), + ...(pageToken && { pageToken }), + } + + try { + const response = await this.youtubeClient.comments.list(requestParams) + // const response = await this.youtubeApiService.getCommentsList(requestParams) + return response + } + catch (error) { + return error + } + } + + /** + * 创建对现有评论的回复 + * @param accountId 账号ID + * @param snippet 元数据 + * @returns 创建结果 + */ + async insertComment(accountId: string, parentId: string, textOriginal: string) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody = { + snippet: { + parentId, + textOriginal, + }, + } + // this.logger.log(requestBody) + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.comments.insert({ + auth: this.oauth2Client, + part: ['snippet', 'id'], + requestBody, + }) + + return response + } + catch (error) { + this.logger.log('Error uploading video:', error) + return error + } + } + + /** + * 更新评论。 + * @param snippet 元数据 + * @returns 创建结果 + */ + async updateComment(accountId: string, id: string, textOriginal: string) { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + const response = await this.youtubeClient.comments.update({ + auth: this.oauth2Client, + part: ['snippet', 'id'], + requestBody: { + snippet: { + textOriginal, + }, + id, + }, + }) + + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 设置一条或多条评论的审核状态。 + * @param accountId 账号ID + * @param id 评论ID + * @param moderationStatus 审核状态 + * @param banAuthor 是否禁止作者 + * @returns 设置结果 + */ + async setModerationStatusComments( + accountId: string, + id: string[], + moderationStatus: string, + banAuthor: boolean, + ): Promise { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 调用 YouTube API 上传视频 + const response + = await this.youtubeClient.comments.setModerationStatus({ + auth: this.oauth2Client, + id, + moderationStatus, // heldForReview 等待管理员审核 published - 清除要公开显示的评论。 rejected - 不显示该评论 + banAuthor, // 自动拒绝评论作者撰写的任何其他评论 将作者加入黑名单, + }) + // 返回上传的视频 ID + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 删除评论 + * @param id 评论ID + * @returns 删除结果 + */ + async deleteComment(accountId, id) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + try { + const response = await this.youtubeClient.comments.delete({ + auth: this.oauth2Client, + id, + }) + this.logger.log('comment deleted:', response.data) + return response + } + catch (error) { + this.logger.error('Error deleting comment:', error) + return error + } + } + + /** + * 获取评论会话列表。 + */ + async getCommentThreadsList( + accountId: string, + allThreadsRelatedToChannelId?: string, + id?: string[], + videoId?: string, + maxResults?: number, + pageToken?: string, + order?: string, + searchTerms?: string, + ) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, // 使用授权的 access token + part: 'id, snippet', + ...(id && { id }), // id 已为 string[] 类型 + ...(allThreadsRelatedToChannelId && { allThreadsRelatedToChannelId }), + ...(videoId && { videoId }), + ...(order && { order }), + ...(maxResults && { maxResults }), + ...(pageToken && { pageToken }), + ...(searchTerms && { searchTerms }), + } + + // // 根据参数选择 `id` 或 `forUsername` + // if (id) { + // requestParams.id = id // 如果提供了 id, 使用 id + // } + // else if (allThreadsRelatedToChannelId) { + // requestParams.allThreadsRelatedToChannelId = allThreadsRelatedToChannelId // 如果提供了 handle, 使用 handle + // } + // else if (maxResults) { + // requestParams.maxResults = maxResults // 如果提供了 handle, 使用 handle + // } + // else if (pageToken) { + // requestParams.pageToken = pageToken // 如果提供了 handle, 使用 handle + // } + // else if (videoId) { + // requestParams.videoId = videoId // 如果提供了 handle, 使用 handle + // } + // else if (order) { + // requestParams.order = order // 如果提供了 handle, 使用 handle + // } + // else if (searchTerms) { + // requestParams.searchTerms = searchTerms // 如果提供了 handle, 使用 handle + // } + + try { + const response + = await this.youtubeClient.commentThreads.list(requestParams) + const sections = response.data + this.logger.log(sections) + return response + } + catch (err) { + this.logger.error(`The API returned an error: ${err}`) + return err + } + } + + /** + * 创建顶级评论 + */ + async insertCommentThreads(accountId, channelId, videoId, textOriginal) { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + const response = await this.youtubeClient.commentThreads.insert({ + auth: this.oauth2Client, + part: ['snippet', 'id'], + requestBody: { + snippet: { + channelId, + videoId, + topLevelComment: { + snippet: { + textOriginal, + }, + }, + }, + }, + }) + + return response + } + catch (error) { + this.logger.log(error) + return error + } + } + + /** + * 对视频的点赞、踩。 + */ + async setVideosRate(accountId, videoId, rating) { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 调用 API 进行点赞或踩 + const response = await this.youtubeClient.videos.rate({ + auth: this.oauth2Client, + id: videoId, + rating, // like | dislike | none, + }) + + return response + } + catch (error) { + this.logger.error('Error rating video:', error) + // this.handleApiError(error); + return error + } + } + + /** + * 获取视频的点赞、踩。 + */ + async getVideosRating(accountId: string, videoIds: string[]) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, + id: videoIds, + } + + try { + const response + = await this.youtubeClient.videos.getRating(requestParams) + + const infos = response.data + this.logger.log(infos) + return response + } + catch (err) { + this.logger.error(err) + return err + } + } + + /** + * 删除视频 + */ + async deleteVideo(accountId: string, videoId: string) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + try { + const response = await this.youtubeClient.videos.delete({ + auth: this.oauth2Client, + id: videoId, + }) + + this.logger.log('Video deleted:', response.data) + return response + } + catch (error) { + this.logger.error('Error deleting video:', error) + return error + } + } + + /** + * 更新视频。 + */ + async updateVideo(accountId: string, videoId: string, snippet: any, status: any, recordingDetails: any) { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + const requestBody: any = { + id: videoId, + snippet, + status, + recordingDetails, + } + this.logger.log(requestBody) + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.videos.update( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id'], + requestBody, + }, + ) + this.logger.log('Playlist insert successfully:', response.data) + + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 创建播放列表。 + */ + async insertPlayList(accountId: string, snippet: any, status: any) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + // const requestBody = { + // snippet: { + // title: title, + // description: description + // }, + // status: { + // privacyStatus: privacyStatus, // 可以是 'public', 'private', 'unlisted' + // }, + // }; + const requestBody = { + snippet, + status, + } + + // console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.playlists.insert( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id', 'contentDetails'], + requestBody, + }, + ) + // this.logger.log('Playlist insert successfully:', response.data) + // 返回上传的视频 ID + if (response.data) { + this.logger.log('Playlist insert successfully:', response.data) + } + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 获取播放列表。 + */ + async getPlayList(accountId: string, channelId?: string, id?: string, mine?: boolean, maxResults?: number, pageToken?: string) { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, + part: ['snippet', 'contentDetails', 'id', 'status', 'topicDetails', 'player'], + // id: ids + } + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id // 如果提供了 id, 使用 id + } + else if (channelId) { + requestParams.channelId = channelId // 如果提供了 handle, 使用 handle + } + else if (mine !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (mine) { + requestParams.mine = true // 请求当前登录用户的频道 + } + } + else if (maxResults) { + requestParams.maxResults = maxResults // 如果提供了 handle, 使用 handle + } + else if (pageToken) { + requestParams.pageToken = pageToken // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeClient.playlists.list(requestParams) + + // const infos = response.data + return response + } + catch (err) { + this.logger.log(`The API returned an error: ${err}`) + return err + } + } + + /** + * 更新播放列表。 + */ + async updatePlayList(accountId: string, id: string, title: string, description?: string, privacyStatus?: string, podcastStatus?: string) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody: any = { + id, // 必填 + snippet: { + title, + // description: description, + }, // 类型断言 + status: { + // privacyStatus: privacyStatus, + }, // 类型断言 + } + + // 根据参数选择 `title`、`description`、`privacyStatus` 或 `podcastStatus` + + if (description) { + requestBody.snippet.description = description // 如果提供了 id, 使用 id + } + + if (privacyStatus || podcastStatus) { + if (privacyStatus) { + requestBody.status.privacyStatus = privacyStatus // 如果提供了 id, 使用 id + } + if (podcastStatus) { + requestBody.status.podcastStatus = podcastStatus // 如果提供了 id, 使用 id + } + } + this.logger.log(requestBody) + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.playlists.update( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id'], + requestBody, + }, + ) + + // 返回上传的视频 ID + if (response.data) { + this.logger.log('Playlist insert successfully:', response.data) + return response + } + else { + return response + } + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 删除播放列表 + */ + async deletePlaylist(accountId: string, playListId: string) { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + try { + const response = await this.youtubeClient.playlists.delete({ + auth: this.oauth2Client, + id: playListId, + }) + this.logger.log('Video deleted:', response.data) + return response + } + catch (error) { + this.logger.error('Error deleting video:', error) + return error + } + } + + /** + * 将视频添加到播放列表中 + */ + async addVideoToPlaylist(accountId: string, snippet: any, contentDetails: any) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody = { + snippet, + contentDetails, + } + + // console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.playlistItems.insert( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id', 'contentDetails'], + requestBody, + }, + ) + + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 获取播放列表项。 + */ + async getPlayItemsList(accountId: string, id?: string, playlistId?: string, maxResults?: number, pageToken?: string, videoId?: string) { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, + part: ['snippet', 'contentDetails', 'id', 'status'], + // id: ids + } + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id // 如果提供了 id, 使用 id + } + else if (playlistId) { + requestParams.playlistId = playlistId // 如果提供了 handle, 使用 handle + } + else if (maxResults) { + requestParams.maxResults = maxResults // 如果提供了 handle, 使用 handle + } + else if (pageToken) { + requestParams.pageToken = pageToken // 如果提供了 handle, 使用 handle + } + else if (videoId) { + requestParams.videoId = videoId // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeClient.playlistItems.list(requestParams) + + // const infos = response.data + // console.log(infos); + return response + } + catch (err) { + this.logger.log(`The API returned an error: ${err}`) + return err + } + } + + /** + * 插入播放列表项。 + */ + async insertPlayItems(accountId: string, playlistId: string, resourceId: string, position?: number, note?: string) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody: any = { + snippet: { + playlistId, + resourceId, + }, + contentDetails: {}, + } + + // 如果传递了 position,则添加到请求体 + if (position !== undefined) { + requestBody.snippet.position = position + } + + // 如果传递了 note,则添加到请求体 + if (note !== undefined) { + requestBody.contentDetails.note = note + } + // console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.playlistItems.insert( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id', 'contentDetails'], + requestBody, + }, + ) + + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 更新播放列表项。 + */ + async updatePlayItems(accountId: string, playlistItemsId: string, snippet: any, contentDetails: any) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + const requestBody: any = { + id: playlistItemsId, + snippet, + contentDetails, + } + // console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.playlistItems.update( + { + auth: this.oauth2Client, + part: ['snippet', 'status', 'id'], + requestBody, + }, + ) + + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 删除播放列表项 + */ + async deletePlayItems(accountId: string, playlistItemsId: string) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + const response = await this.youtubeClient.playlistItems.delete({ + auth: this.oauth2Client, + id: playlistItemsId, + }) + // console.log('Video deleted:', response.data); + return response + } + catch (error) { + this.logger.error('Error deleting video:', error) + return error + } + } + + /** + * 获取频道列表 + * @param userId 用户ID + * @param handle 频道handle + * @param userName 用户名 + * @param id 频道ID + * @param mine 是否查询自己的频道 + * @returns 频道列表 + */ + // async getChannelsList(params: GetChannelsListParams) { + async getChannelsList(accountId: string, forHandle?: string, forUsername?: string, id?: string[], mine?: boolean, maxResults?: number, pageToken?: string) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, // 使用授权的 access token + part: ['snippet', 'contentDetails', 'statistics', 'status', 'topicDetails'], + ...(id && { id }), // id 已为 string[] 类型 + ...(forHandle && { forHandle }), + ...(forUsername && { forUsername }), + ...(mine && { mine: true }), + ...(maxResults && { maxResults }), + ...(pageToken && { pageToken }), + } + + try { + const response = await this.youtubeClient.channels.list(requestParams) + return response + } + catch (err) { + this.logger.error(err) + return err + } + } + + /** + * 更新频道 + * @param accessToken + * @param ChannelId 频道ID + * @param brandingSettings 品牌设置 + * @param status 状态 + * @returns 更新结果 + */ + async updateChannels(accountId, ChannelId, brandingSettings, status) { + try { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody: any = { + id: ChannelId, + } + + // 如果传递了 note,则添加到请求体 + if (brandingSettings !== undefined) { + requestBody.brandingSettings = brandingSettings + } + if (status !== undefined) { + requestBody.status = status + } + + // console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.channelSections.update( + { + auth: this.oauth2Client, + part: ['brandingSettings'], + requestBody, + }, + ) + + return response + } + catch (error) { + this.logger.error('Error Channels update:', error) + return error + } + } + + /** + * 获取频道板块列表 + * @param accessToken + * @param channelId 频道ID + * @param id 板块ID + * @param mine 是否查询自己的板块 + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 频道板块列表 + */ + async getChannelSectionsList(accountId: string, channelId?: string, id?: string[], mine?: boolean) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + return '获取访问令牌失败' + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, // 使用授权的 access token + part: ['contentDetails', 'id', 'snippet'], + ...(channelId && { channelId }), + ...(id && { id }), // id 已为 string[] 类型 + ...(mine && { mine: true }), + } + + // // 根据参数选择 `id` 或 `forUsername` + // if (id) { + // requestParams.id = id // 如果提供了 id, 使用 id + // } + // else if (channelId) { + // requestParams.channelId = channelId // 如果提供了 handle, 使用 handle + // } + // else if (mine !== undefined) { + // // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + // if (mine) { + // requestParams.mine = true // 请求当前登录用户的频道 + // } + // } + + try { + const response = await this.youtubeClient.channelSections.list(requestParams) + return response + } + catch (err) { + this.logger.log(`The API returned an error: ${err}`) + return err + } + } + + /** + * 创建频道板块。 + * + * @param snippet 元数据 + * @param contentDetails 内容详情 + * @returns 创建结果 + */ + async insertChannelSection(accountId, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + // 构造请求体 + const requestBody = { + snippet, + contentDetails, + } + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.channelSections.insert( + { + auth: this.oauth2Client, + part: ['snippet', 'id', 'contentDetails'], + requestBody, + }, + ) + + return response + } + catch (error) { + this.logger.error('Error Channel Section insert:', error) + return error + } + } + + /** + * 更新频道板块。 + * @param snippet 元数据 + * @param contentDetails 内容详情 + * @returns 创建结果 + */ + async updateChannelSection(accountId, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 构造请求体 + const requestBody = { + snippet, + contentDetails, + } + + // 调用 YouTube API 上传视频 + const response = await this.youtubeClient.channelSections.update( + { + auth: this.oauth2Client, + part: ['snippet', 'id', 'contentDetails'], + requestBody, + }, + ) + // 返回上传的视频 ID + return response + } + catch (error) { + this.logger.error('Error uploading video:', error) + return error + } + } + + /** + * 删除频道板块 + * @param channelSectionId 频道板块ID + * @returns 删除结果 + */ + async deleteChannelsSections(accountId, channelSectionId) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + try { + const response = await this.youtubeClient.channelSections.delete({ + auth: this.oauth2Client, + id: channelSectionId, + }) + this.logger.log('Video deleted:', response.data) + return response + } + catch (error) { + this.logger.error('Error deleting video:', error) + return error + } + } + + // 上传缩略图 + async uploadThumbnails(accountId, videoId, thumbnail) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + try { + const response = await this.youtubeClient.thumbnails.set({ + auth: this.oauth2Client, + videoId, + media: thumbnail, + }) + // this.logger.log('Video deleted:', response.data) + return response?.data?.items?.[0]?.default?.url + } + catch (error) { + this.logger.error('Error deleting video:', error) + return false + } + } + + /** + * 安全地获取访问令牌并初始化YouTube客户端 + * @param accountId 账号ID + * @returns 成功返回true,失败返回false + */ + private async ensureValidAccessToken(accountId: string): Promise { + const accessToken = await this.getUserAccessToken(accountId) + + if (!accessToken) { + return false + } + this.initializeYouTubeClient(accessToken) + return true + } + + /** + * 搜索 + * @param userId 用户ID + * @param handle 频道handle + * @param userName 用户名 + * @param id 频道ID + * @param mine 是否查询自己的频道 + * @returns 频道列表 + */ + async getSearchList( + accountId: string, + forMine?: boolean, + maxResults?: number, + order?: string, // 排序的方法。默认值为 relevance。其他可选date|rating(评分从高到低) |title|videoCount |viewCount + pageToken?: string, + publishedBefore?: Date, + publishedAfter?: Date, + q?: string, // 搜索的查询字词 + type?: string, // 默认video,其他可选 channel、playlist + videoCategoryId?: string, + ) { + // 使用封装的辅助方法 + if (!(await this.ensureValidAccessToken(accountId))) { + this.logger.log(`get youtube access token error. accountId" ${accountId}`) + return new AppException(ExceptionCode.Failed, 'get access token error.') + } + + // 根据传入的参数来选择一个有效的请求参数 + const requestParams: any = { + auth: this.oauth2Client, // 使用授权的 access token + part: ['snippet'], + ...(forMine && { forMine: true }), + ...(maxResults && { maxResults }), + ...(pageToken && { pageToken }), + ...(order && { order }), + ...(publishedBefore && { publishedBefore }), + ...(publishedAfter && { publishedAfter }), + ...(q && { q }), + ...(type && { type }), + ...(videoCategoryId && { videoCategoryId }), + } + + try { + const response = await this.youtubeClient.search.list(requestParams) + return response + } + catch (err) { + this.logger.error(err) + return err + } + } + + async getAccessTokenStatus(accountId: string): Promise { + const credential = await this.getOAuth2Credential(accountId) + if (credential && credential.access_token) { + return 1 + } + return 0 + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/common.ts new file mode 100644 index 000000000..2dff08927 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/common.ts @@ -0,0 +1,51 @@ +import { InitUploadVideoDto } from '../../core/plat/youtube/dto/youtube.dto' +import { AddArchiveData } from '../../libs/bilibili/common' +import { + PublishStatus, + PublishTask, +} from '../../libs/database/schema/publishTask.schema' +import { FacebookPost } from '../../libs/facebook/facebook.interfaces' +import { InstagramPost } from '../../libs/instagram/instagram.interfaces' +import { IPinterestOptions } from '../../libs/pinterest/common' +import { ThreadsPost } from '../../libs/threads/threads.interfaces' +import { TiktokPostOptions } from '../../libs/tiktok/tiktok.interfaces' +import { WxGzhArticleNewsPic } from '../../libs/wxGzh/common' + +export interface PlatPulOption { + bilibili?: Partial> + & Required> + youtube?: Pick< + InitUploadVideoDto, + 'tag' | 'categoryId' | 'privacyStatus' | 'license' | 'embeddable' | 'notifySubscribers' | 'selfDeclaredMadeForKids' + > + wxGzh?: Pick< + WxGzhArticleNewsPic, + | 'need_open_comment' + | 'only_fans_can_comment' + | 'cover_info' + | 'product_info' + > + facebook?: FacebookPost + instagram?: InstagramPost + threads?: ThreadsPost + pinterest?: IPinterestOptions + tiktok?: TiktokPostOptions +} + +export interface NewPulData + extends Omit< + PublishTask, + 'id' | 'option' | 'status' | 'createdAt' | 'updatedAt' + > { + option?: T +} + +export interface DoPubRes { + status: PublishStatus + message: string + noRetry?: boolean + data?: any +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/constant.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/constant.ts new file mode 100644 index 000000000..422028f55 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/constant.ts @@ -0,0 +1,11 @@ +import { CronExpression } from '@nestjs/schedule' + +// 定时任务间隔时间,1 分钟 +export const PUSH_SCHEDULED_TASK_CRON_EXPRESSION = CronExpression.EVERY_10_MINUTES +// export const TIMED_TASK_INTERVAL = 2000 + +// 定时任务扫码跨度,在当前时间前后 ‘CRON_SCAN_WINDOW_MS’ 分钟内时间的任务才会被推送到任务队列 +export const PUSH_SCHEDULED_TASK_QUERY_WINDOW_MS = 10 * 60 * 1000 +// 发布任务时,如果在当前时间 + 'IMMEDIATE_PUBLISH_THRESHOLD_MS'内,则直接推送到任务队列 +export const IMMEDIATE_PUSH_THRESHOLD_MS = 60 * 60 * 1000 * 2 +// export const IMMEDIATE_PUSH_THRESHOLD_MS = 2 * 60 * 60 * 100000 diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/meta.container.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/meta.container.dto.ts new file mode 100644 index 000000000..deead9d31 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/meta.container.dto.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { PostCategory, PostMediaStatus, PostSubCategory } from '../../../libs/database/schema/postMediaContainer.schema' + +export const PostMediaContainer = z.object({ + publishId: z.string().describe('发布任务ID'), + userId: z.string().describe('用户ID'), + platform: z.string().describe('平台'), + taskId: z.string().describe('任务ID'), + status: z.enum(PostMediaStatus).describe('任务状态').default(PostMediaStatus.CREATED), + category: z.enum(PostCategory).describe('任务类别').default(PostCategory.POST), + subCategory: z.enum(PostSubCategory).describe('任务子类别').default(PostSubCategory.PLAINTEXT), + accountId: z.string().describe('账户ID'), + option: z.any().optional(), +}) + +export class CreatePostMediaContainerDto { + constructor(data: z.infer) { + Object.assign(this, data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/publish.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/publish.dto.ts new file mode 100644 index 000000000..02ed9b79f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/publish.dto.ts @@ -0,0 +1,299 @@ +import { ApiProperty } from '@nestjs/swagger' +import { AccountType } from '@yikart/aitoearn-server-client' +import { createZodDto } from '@yikart/common' +import { Expose, Transform } from 'class-transformer' +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDate, + IsEnum, + IsOptional, + IsString, +} from 'class-validator' +import { ObjectId } from 'mongodb' +import { v4 as uuid } from 'uuid' +import { z } from 'zod' +import { + PublishStatus, + PublishType, +} from '../../../libs/database/schema/publishTask.schema' + +export class PublishRecordIdDto { + @ApiProperty({ title: 'ID', required: true }) + @IsString({ message: 'ID' }) + @Expose() + readonly id: string +} + +export const UpPublishTaskTimeSchema = z.object({ + id: z.string({ message: '任务ID' }), + publishTime: z + .date({ message: '发布日期不能为空' }) + .default(() => new Date()), + userId: z.string({ message: 'userId不能为空' }), +}) +export type UpPublishTaskTimeDto = z.infer + +export const DeletePublishTaskSchema = z.object({ + id: z.string({ message: '任务ID' }), + userId: z.string({ message: 'userId不能为空' }), +}) +export class DeletePublishTaskDto extends createZodDto( + DeletePublishTaskSchema, +) {} + +export enum BilibiliNoReprint { + No = 1, + Yes = 0, +} +export enum Copyright { + Original = 1, // 原创 + Reprint = 2, +} + +export const BiliBiliPublishOptionSchema = z.object({ + tid: z.number().int().positive(), + no_reprint: z.enum(BilibiliNoReprint).optional(), + copyright: z.enum(Copyright), + source: z.string().optional(), +}) + +export const WxGzhPublishOptionSchema = z.object({ + open_comment: z.number().int().optional(), + only_fans_can_comment: z.number().int().optional(), +}) + +export enum YouTubePrivacyStatus { + Public = 'public', + Unlisted = 'unlisted', + Private = 'private', +} + +export enum YouTubeLicense { + CreativeCommons = 'creativeCommons', + YouTube = 'youtube', +} + +export const YouTubePublishOptionSchema = z.object({ + privacyStatus: z.enum([ + YouTubePrivacyStatus.Public, + YouTubePrivacyStatus.Unlisted, + YouTubePrivacyStatus.Private, + ]), + license: z.enum(YouTubeLicense).optional(), + categoryId: z.string(), + notifySubscribers: z.boolean().optional().default(false), + embeddable: z.boolean().optional().default(false), + selfDeclaredMadeForKids: z.boolean().optional().default(false), +}) + +export const FacebookPublishOptionSchema = z.object({ + page_id: z.string().optional(), + content_category: z.string().optional(), + content_tags: z.array(z.string()).optional(), + custom_labels: z.array(z.string()).optional(), + direct_share_status: z.number().int().optional(), + embeddable: z.boolean().optional(), +}) + +export const InstagramPublishOptionSchema = z.object({ + content_category: z.string().optional(), + alt_text: z.string().optional(), + caption: z.string().optional(), + collaborators: z.array(z.string()).optional(), + cover_url: z.string().optional(), + image_url: z.string().optional(), + location_id: z.string().optional(), + product_tags: z.array(z.object({ + product_id: z.string(), + x: z.number(), + y: z.number(), + })).optional(), + user_tags: z.array(z.object({ + username: z.string(), + x: z.number(), + y: z.number(), + })).optional(), +}) + +export const threadsPublishOptionSchema = z.object({ + reply_control: z.string().optional(), + allowlisted_country_codes: z.array(z.string()).optional(), + alt_text: z.string().optional(), + auto_publish_text: z.boolean().optional(), + topic_tags: z.string().optional(), + location_id: z.string().optional(), +}) + +export const pinterestPublishOptionSchema = z.object({ + boardId: z.string().optional(), +}) + +export const TiktokPublishOptionSchema = z.object({ + privacy_level: z.enum(['PUBLIC_TO_EVERYONE', 'MUTUAL_FOLLOW_FRIENDS', 'SELF_ONLY', 'FOLLOWER_OF_CREATOR']), + disable_duet: z.boolean().optional(), + disable_stitch: z.boolean().optional(), + disable_comment: z.boolean().optional(), + brand_organic_toggle: z.boolean().optional(), + brand_content_toggle: z.boolean().optional(), +}) + +export const CreatePublishSchema = z.object({ + flowId: z.string({ message: '流水ID' }).optional().default(uuid()), + accountId: z.string({ message: '账户ID' }), + accountType: z.enum(AccountType, { message: '平台类型' }), + type: z.enum(PublishType, { message: '类型' }), + title: z.string().optional(), + desc: z.string().optional(), + userTaskId: z.string({ message: '用户任务ID' }).optional(), // 用户任务ID + taskMaterialId: z.string({ message: '任务素材ID' }).optional(), // 任务素材ID + videoUrl: z.string().optional(), + coverUrl: z.string().optional(), + imgUrlList: z.array(z.string()).optional(), + publishTime: z.union([z.date(), z.string().datetime()]).transform(arg => new Date(arg)), + topics: z.array(z.string()), + option: z.object({ + bilibili: BiliBiliPublishOptionSchema.optional(), + wxGzh: WxGzhPublishOptionSchema.optional(), + youtube: YouTubePublishOptionSchema.optional(), + facebook: FacebookPublishOptionSchema.optional(), + instagram: InstagramPublishOptionSchema.optional(), + threads: threadsPublishOptionSchema.optional(), + pinterest: pinterestPublishOptionSchema.optional(), + tiktok: TiktokPublishOptionSchema.optional(), + }).optional(), +}) +export class CreatePublishDto extends createZodDto(CreatePublishSchema) {} + +/** + * 创建发布记录 + */ +export const CreatePublishRecordSchema = z.object({ + flowId: z.string({ message: '流水ID' }).optional(), + dataId: z.string({ message: '数据ID' }), + userId: z.string({ message: '用户ID' }), + uid: z.string({ message: '频道账户ID' }), + accountId: z.string({ message: '账户ID' }), + accountType: z.enum(AccountType, { message: '平台类型' }), + type: z.enum(PublishType, { message: '类型' }), + status: z.enum(PublishStatus, { message: '状态' }), + title: z.string().optional(), + desc: z.string().optional(), + userTaskId: z.string({ message: '用户任务ID' }).optional(), // 用户任务ID + taskId: z.string({ message: '任务ID' }).optional(), // 任务ID + taskMaterialId: z.string({ message: '任务素材ID' }).optional(), // 任务素材ID + videoUrl: z.string().optional(), + coverUrl: z.string().optional(), + imgList: z.array(z.string()).optional(), + publishTime: z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + topics: z.array(z.string()), + option: z.object({ + bilibili: BiliBiliPublishOptionSchema.optional(), + // wxGzh: WxGzhPublishOptionSchema.optional(), + youtube: YouTubePublishOptionSchema.optional(), + facebook: FacebookPublishOptionSchema.optional(), + instagram: InstagramPublishOptionSchema.optional(), + threads: threadsPublishOptionSchema.optional(), + pinterest: pinterestPublishOptionSchema.optional(), + }).optional(), +}) +export class CreatePublishRecordDto extends createZodDto(CreatePublishRecordSchema) {} + +export class PublishRecordListFilterDto { + @IsString({ message: '用户ID' }) + @Expose() + readonly userId: string + + @IsString({ message: '账户ID' }) + @IsOptional() + @Expose() + readonly accountId?: string + + @IsString({ message: '第三方平台用户id' }) + @IsOptional() + @Expose() + readonly uid?: string + + @ApiProperty({ + title: '账户类型', + required: false, + enum: AccountType, + description: '账户类型', + }) + @IsEnum(AccountType, { message: '账户类型' }) + @IsOptional() + @Expose() + readonly accountType?: AccountType + + @ApiProperty({ + title: '类型', + required: false, + enum: PublishType, + description: '类型', + }) + @IsEnum(PublishType, { message: '类型' }) + @IsOptional() + @Expose() + readonly type?: PublishType + + @ApiProperty({ + title: '状态', + required: false, + enum: PublishStatus, + description: '状态', + }) + @IsEnum(PublishStatus, { message: '状态' }) + @IsOptional() + @Expose() + readonly status?: PublishStatus + + @ApiProperty({ title: '创建时间区间', required: false }) + @IsArray({ message: '创建时间区间必须是一个数组' }) + @ArrayMinSize(2, { message: '创建时间区间必须包含两个日期' }) + @ArrayMaxSize(2, { message: '创建时间区间必须包含两个日期' }) + @IsDate({ each: true, message: '创建时间区间中的每个元素必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => + value ? value.map((v: string) => new Date(v)) : undefined, + ) + readonly time?: [Date, Date] +} + +// 立即发布 dto +export const NowPubTaskSchema = z.object({ + id: z.string({ message: '任务ID' }), +}) +export class NowPubTaskDto extends createZodDto(NowPubTaskSchema) {} + +export const PublishDayInfoListFiltersSchema = z.object({ + userId: z.string().optional(), + time: z.tuple([ + z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + ]).optional(), +}) +export class PublishDayInfoListFiltersDto extends createZodDto(PublishDayInfoListFiltersSchema) {} + +export const PublishDayInfoListSchema = z.object({ + filters: PublishDayInfoListFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class PublishDayInfoListDto extends createZodDto(PublishDayInfoListSchema) {} + +export const GetPublishRecordDetailSchema = z.object({ + id: z.string({ message: 'ID is required' }).refine(val => ObjectId.isValid(val), { message: 'Invalid Publish Record ID' }), + userId: z.string({ message: 'userId is required' }), +}) + +export class GetPublishRecordDetailDto extends createZodDto(GetPublishRecordDetailSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/tiktok.webhook.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/tiktok.webhook.dto.ts new file mode 100644 index 000000000..1cc43e783 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/dto/tiktok.webhook.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' + +export const TiktokWebhookSchema = z.object({ + client_key: z.string(), + event: z.string(), + create_time: z.number(), + user_openid: z.string(), + content: z.string(), +}) + +export type TiktokWebhookDto = z.infer diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/bilibiliPub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/bilibiliPub.service.ts new file mode 100644 index 000000000..bf4847249 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/bilibiliPub.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { + fileUrlToBase64, + getFileTypeFromUrl, + streamDownloadAndUpload, +} from '../../../common' +import { BilibiliService } from '../../../core/plat/bilibili/bilibili.service' +import { + PublishStatus, + PublishTask, +} from '../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../common' +import { PublishBase } from './publish.base' + +@Injectable() +export class BilibiliPubService extends PublishBase { + override queueName: string = AccountType.BILIBILI + private readonly logger = new Logger(BilibiliPubService.name) + + constructor( + readonly bilibiliService: BilibiliService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + doPub(publishTask: PublishTask): Promise { + return new Promise(async (resolve) => { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + + // 封面上传 + const { coverUrl, accountId, videoUrl } = publishTask + let biblCoverUrl = '' + // 有封面 + if (coverUrl) { + try { + this.logger.log('正在上传封面...') + const urlBase64 = await fileUrlToBase64(coverUrl) + const coverRes = await this.bilibiliService.coverUpload( + accountId, + urlBase64, + ) + if (!coverRes) { + res.message = '封面上传失败' + return resolve(res) + } + biblCoverUrl = coverRes + + this.logger.log('封面上传成功:', coverRes) + } + catch (e) { + res.message = '封面上传失败' + this.logger.log('封面上传失败', e) + return resolve(res) + } + } + + if (!videoUrl) { + res.message = '视频不存在' + res.noRetry = true + return resolve(res) + } + const fileName = getFileTypeFromUrl(videoUrl) + + this.logger.log('正在分片上传...') + // 视频分片上传初始化 + const videoUpToken = await this.bilibiliService.videoInit( + accountId, + fileName, + 0, + ) + if (!videoUpToken) { + res.message = '视频初始化失败' + return resolve(res) + } + + // 视频URL分片上传 + void streamDownloadAndUpload( + videoUrl, + async (upData: Buffer, partNumber: number) => { + this.logger.log(`分片:${partNumber}`) + await this.bilibiliService.uploadVideoPart( + accountId, + upData, + videoUpToken, + partNumber, + ) + }, + async () => { + this.logger.log('合并分片...') + // 合并 + await this.bilibiliService.videoComplete(accountId, videoUpToken) + + this.logger.log('发布...') + // 发布 + const resourceId = await this.bilibiliService.archiveAddByUtoken( + accountId, + videoUpToken, + { + title: publishTask.title || '', + cover: biblCoverUrl, + desc: publishTask.desc, + ...publishTask.option!.bilibili!, + tag: publishTask.topics?.join(','), + }, + ) + + if (!resourceId) { + res.message = '稿件发布失败' + return resolve(res) + } + + // 完成发布任务 + void this.completePublishTask(publishTask, resourceId, { + workLink: `https://www.bilibili.com/video/${resourceId}`, + }) + res.message = '发布成功' + res.status = 1 + + resolve(res) + }, + ).catch((e) => { + resolve({ + message: e.message, + status: PublishStatus.FAILED, + }) + }) + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/kwaiPub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/kwaiPub.service.ts new file mode 100644 index 000000000..4ddec644c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/kwaiPub.service.ts @@ -0,0 +1,74 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: b站 + */ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { KwaiService } from '../../../core/plat/kwai/kwai.service' +import { + PublishStatus, + PublishTask, +} from '../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../common' +import { PublishBase } from './publish.base' + +@Injectable() +export class kwaiPubService extends PublishBase { + override queueName: string = AccountType.KWAI + + private readonly logger: Logger = new Logger(kwaiPubService.name) + constructor( + readonly kwaiService: KwaiService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + async doPub(publishTask: PublishTask): Promise { + this.logger.log(`Processing Kwai Publish Task: ${publishTask.id}`) + try { + const res = await this.kwaiService.publishVideo(publishTask.accountId, { + coverUrl: publishTask.coverUrl!, + videoUrl: publishTask.videoUrl!, + describe: publishTask.desc, + }) + this.logger.log(`Kwai Publish Result: ${JSON.stringify(res)}`) + + if (res.success) { + void this.completePublishTask(publishTask, res.worksId!, { + workLink: `https://www.kuaishou.com/short-video/${res.worksId}`, + }) + return { + message: '发布成功', + status: PublishStatus.PUBLISHED, + } + } + else { + return { + message: res.failMsg || '发布失败,未知原因', + status: PublishStatus.FAILED, + } + } + } + catch (e) { + return { + message: e.message || '发布失败,未知原因', + status: PublishStatus.FAILED, + } + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/container.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/container.service.ts new file mode 100644 index 000000000..20f2d56fd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/container.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { PostMediaContainer, PostMediaStatus } from '../../../../libs/database/schema/postMediaContainer.schema' +import { CreatePostMediaContainerDto } from '../../dto/meta.container.dto' + +@Injectable() +export class PostMediaContainerService { + constructor( + @InjectModel(PostMediaContainer.name) + private readonly metaPostMediaModel: Model, + ) {} + + async createMetaPostMedia( + data: CreatePostMediaContainerDto, + ): Promise { + const subPublishTask = new this.metaPostMediaModel(data) + return subPublishTask.save() + } + + async getContainers(publishId: string): Promise { + return this.metaPostMediaModel.find({ publishId }).exec() + } + + async getUnProcessedContainers(publishId: string): Promise { + return this.metaPostMediaModel.find({ publishId, $or: [ + { status: PostMediaStatus.CREATED }, + { status: PostMediaStatus.IN_PROGRESS }, + ] }).exec() + } + + async updateContainer( + id: string, + data: Partial, + ): Promise { + return this.metaPostMediaModel.findByIdAndUpdate( + id, + data, + { new: true }, + ).exec() + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/facebook.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/facebook.service.ts new file mode 100644 index 000000000..25b083c89 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/facebook.service.ts @@ -0,0 +1,573 @@ +import { Injectable, Logger } from '@nestjs/common' +import { + chunkedDownloadFile, + fileUrlToBlob, + getRemoteFileSize, +} from '../../../../common' +import { FacebookService } from '../../../../core/plat/meta/facebook.service' +import { + PostCategory, + PostMediaStatus, + PostSubCategory, +} from '../../../../libs/database/schema/postMediaContainer.schema' +import { + PublishStatus, + PublishTask, +} from '../../../../libs/database/schema/publishTask.schema' +import { + ChunkedVideoUploadRequest, + FacebookInitialVideoUploadRequest, + FacebookReelRequest, + finalizeVideoUploadRequest, + PublishFeedPostRequest, + PublishVideoPostRequest, +} from '../../../../libs/facebook/facebook.interfaces' +import { DoPubRes } from '../../common' +import { PublishBase } from '../publish.base' +import { PostMediaContainerService } from './container.service' +import { MetaPostPublisher, PublishMetaPostTask } from './meta.interface' + +@Injectable() +export class FacebookPublishService + extends PublishBase + implements MetaPostPublisher { + override queueName: string = 'facebook' + + private readonly logger = new Logger(FacebookPublishService.name, { + timestamp: true, + }) + + constructor( + readonly facebookService: FacebookService, + private readonly postMediaContainerService: PostMediaContainerService, + ) { + super() + this.postMediaContainerService = postMediaContainerService + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + private generatePostMessage(publishTask: PublishTask): string { + if (!publishTask) { + return '' + } + if (publishTask.topics && publishTask.topics.length > 0) { + return `${publishTask.desc || ''} #${publishTask.topics.join(' #')}` + } + return publishTask.desc || '' + } + + async uploadImage(accountId: string, imgUrl: string): Promise { + const imgBlob = await fileUrlToBlob(imgUrl) + const uploadReq = await this.facebookService.uploadImage( + accountId, + imgBlob.blob, + ) + return uploadReq.id + } + + async publishFeedPost(publishTask: PublishTask): Promise { + this.logger.log(`Received publish task: ${publishTask.id} for Facebook Feed Post`) + this.logger.debug(`Publish task details: ${JSON.stringify(publishTask)}`) + if (!publishTask.desc) { + this.logger.error('Feed Post requires a description') + throw new Error('Feed Post requires a description') + } + const feedPostReq: PublishFeedPostRequest = { + message: this.generatePostMessage(publishTask), + published: true, + } + const postRes = await this.facebookService.publishFeedPost( + publishTask.accountId, + feedPostReq, + ) + const permalink = `https://www.facebook.com/${publishTask.uid}_${postRes.id}` + await this.completePublishTask(publishTask, postRes.id, { + workLink: permalink, + }) + return PublishStatus.PUBLISHED + } + + async publishReelPost(publishTask: PublishTask): Promise { + this.logger.log(`Received publish task: ${publishTask.id} for Facebook Reel`) + this.logger.debug(`Publish task details: ${JSON.stringify(publishTask)}`) + let status = PublishStatus.FAILED + + const { imgUrlList, accountId, videoUrl } = publishTask + if (imgUrlList && imgUrlList.length > 0) { + this.logger.error('Reel does not support image uploads') + throw new Error('Reel does not support image uploads') + } + if (!videoUrl) { + this.logger.error('Reel requires a video URL') + throw new Error('Reel requires a video URL') + } + + const contentLength = await getRemoteFileSize(videoUrl) + const initUploadReq: FacebookReelRequest = { + upload_phase: 'start', + } + const initUploadRes = await this.facebookService.initReelUpload( + accountId, + initUploadReq, + ) + if (!initUploadRes || !initUploadRes.upload_url) { + this.logger.error(`Video initialization upload failed, response: ${JSON.stringify(initUploadRes)}`) + throw new Error('Video initialization upload failed') + } + + const videoFile = await chunkedDownloadFile(videoUrl, [0, contentLength - 1]) + await this.facebookService.uploadReel( + accountId, + initUploadRes.upload_url, + { + offset: 0, + file_size: contentLength, + file: videoFile, + }, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'facebook', + taskId: initUploadRes.video_id, + status: PostMediaStatus.CREATED, + category: PostCategory.STORY, + subCategory: PostSubCategory.VIDEO, + }) + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, // 每次重试间隔 10 秒, 总共尝试 30 次 + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + status = PublishStatus.PUBLISHING + return status + } + + async publishPhotoStory(publishTask: PublishTask): Promise { + this.logger.log(`Received publish task: ${publishTask.id} for Facebook Story`) + this.logger.debug(`Publish task details: ${JSON.stringify(publishTask)}`) + let status = PublishStatus.FAILED + const { imgUrlList, accountId } = publishTask + if (!imgUrlList) { + this.logger.error('Story requires images') + throw new Error('Story requires images') + } + + if (imgUrlList && imgUrlList.length < 1) { + this.logger.error('Story requires at least one image') + throw new Error('Story requires at least one image') + } + + const imgUrl = imgUrlList[0] + const containerId = await this.uploadImage(accountId, imgUrl) + + await this.facebookService.publishPhotoStory( + accountId, + containerId, + ) + const permalink = `https://www.facebook.com/stories/${containerId}` + await this.completePublishTask(publishTask, containerId, { + workLink: permalink, + }) + await this.completePublishTask(publishTask, containerId) + status = PublishStatus.PUBLISHED + return status + } + + async publishVideoStory(publishTask: PublishTask): Promise { + let status = PublishStatus.FAILED + if (!publishTask.videoUrl) { + this.logger.error('Story requires a video URL') + throw new Error('Story requires a video URL') + } + const contentLength = await getRemoteFileSize(publishTask.videoUrl) + const initUploadReq: FacebookReelRequest = { + upload_phase: 'start', + } + const initUploadRes = await this.facebookService.initVideoStoryUpload( + publishTask.accountId, + initUploadReq, + ) + if (!initUploadRes || !initUploadRes.upload_url) { + this.logger.error(`Video initialization upload failed, response: ${JSON.stringify(initUploadRes)}`) + throw new Error('Video initialization upload failed') + } + const videoFile = await chunkedDownloadFile(publishTask.videoUrl, [0, contentLength - 1]) + await this.facebookService.uploadVideoStory( + publishTask.accountId, + initUploadRes.upload_url, + { + offset: 0, + file_size: contentLength, + file: videoFile, + }, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'facebook', + taskId: initUploadRes.video_id, + status: PostMediaStatus.CREATED, + category: PostCategory.STORY, + subCategory: PostSubCategory.VIDEO, + }) + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + status = PublishStatus.PUBLISHING + return status + } + + async publishStory(publishTask: PublishTask): Promise { + this.logger.log(`Received publish task: ${publishTask.id} for Facebook Story`) + this.logger.debug(`Publish task details: ${JSON.stringify(publishTask)}`) + const { imgUrlList, videoUrl } = publishTask + if (!videoUrl && !imgUrlList) { + this.logger.error('Story requires a video or images') + throw new Error('Story requires a video or images') + } + + if (imgUrlList && imgUrlList.length > 0) { + return await this.publishPhotoStory(publishTask) + } + return await this.publishVideoStory(publishTask) + } + + async publishVideo(publishTask: PublishTask): Promise { + this.logger.log(`Received publish task: ${publishTask.id} for Facebook`) + this.logger.debug(`Publish task details: ${JSON.stringify(publishTask)}`) + let status = PublishStatus.FAILED + const { imgUrlList, accountId, videoUrl } = publishTask + const facebookMediaIdList: string[] = [] + if (imgUrlList && imgUrlList.length > 0) { + for (const imgUrl of imgUrlList) { + const imgBlob = await fileUrlToBlob(imgUrl) + const uploadReq = await this.facebookService.uploadImage( + accountId, + imgBlob.blob, + ) + facebookMediaIdList.push(uploadReq.id) + } + if (facebookMediaIdList.length === 0) { + throw new Error('Image upload failed') + } + const publishMediaPost = await this.facebookService.publicPhotoPost( + accountId, + facebookMediaIdList, + this.generatePostMessage(publishTask), + ) + const permalink = `https://www.facebook.com/${publishTask.uid}_${publishMediaPost.id}` + await this.completePublishTask(publishTask, publishMediaPost.id, { + workLink: permalink, + }) + status = PublishStatus.PUBLISHED + return status + } + + if (videoUrl) { + const contentLength = await getRemoteFileSize(videoUrl) + + const initUploadReq: FacebookInitialVideoUploadRequest = { + upload_phase: 'start', + file_size: contentLength, + published: false, + } + const initUploadRes = await this.facebookService.initVideoUpload( + accountId, + initUploadReq, + ) + let startOffset = initUploadRes.start_offset + let endOffset = initUploadRes.end_offset + + while (startOffset < contentLength - 1) { + const range: [number, number] = [startOffset, endOffset - 1] + const videoBlob = await chunkedDownloadFile(videoUrl, range) + const chunkedUploadReq: ChunkedVideoUploadRequest = { + upload_phase: 'transfer', + upload_session_id: initUploadRes.upload_session_id, + start_offset: startOffset, + end_offset: endOffset, + video_file_chunk: videoBlob, + published: false, + } + this.logger.log(`Chunked upload request: start_offset=${startOffset}, end_offset=${endOffset}`) + const chunkedUploadRes + = await this.facebookService.chunkedMediaUpload( + accountId, + chunkedUploadReq, + ) + startOffset = chunkedUploadRes.start_offset + endOffset = chunkedUploadRes.end_offset + } + const finalizeReq: finalizeVideoUploadRequest = { + upload_phase: 'finish', + upload_session_id: initUploadRes.upload_session_id, + published: false, + } + const finalizeRes = await this.facebookService.finalizeMediaUpload( + accountId, + finalizeReq, + ) + if (!finalizeRes.success) { + throw new Error('Video upload finalization failed') + } + const videoPostReq: PublishVideoPostRequest = { + description: this.generatePostMessage(publishTask), + crossposted_video_id: initUploadRes.video_id, + published: true, + } + const postRes = await this.facebookService.publishVideoPost( + accountId, + videoPostReq, + ) + const permalink = `https://www.facebook.com/${publishTask.uid}_${postRes.id}` + await this.completePublishTask(publishTask, postRes.id, { + workLink: permalink, + }) + status = PublishStatus.PUBLISHED + return status + } + return status + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: -1, + message: 'Publish task not found', + } + + const contentCategory = publishTask.option?.facebook?.content_category + if (!contentCategory) { + this.logger.error('Invalid publish task: no Facebook page contentCategory specified') + res.message = 'Invalid publish task: no Facebook page contentCategory specified' + return res + } + const { imgUrlList, videoUrl, desc } = publishTask + if (!imgUrlList && !videoUrl && !desc) { + this.logger.error('Invalid publish task: no media and no description') + res.message = 'Invalid publish task: no media and no description' + return res + } + + try { + switch (contentCategory) { + case 'post': + if (!imgUrlList && !videoUrl) { + res.status = await this.publishFeedPost(publishTask) + res.message = 'Publish feed post successfully' + return res + } + else { + res.status = await this.publishVideo(publishTask) + res.message = 'Video post published successfully' + return res + } + case 'reel': + res.status = await this.publishReelPost(publishTask) + res.message = 'Waiting for media processing' + return res + case 'story': + res.status = await this.publishStory(publishTask) + res.message = 'Story published successfully' + if (res.status === PublishStatus.PUBLISHING) { + res.message = 'Waiting for media processing' + } + return res + default: + this.logger.error(`Unsupported content category: ${contentCategory}`) + res.message = `Unsupported content category: ${contentCategory}` + return res + } + } + catch (error) { + this.logger.error(`Publish task failed: ${error.message}`, error.stack) + res.message = error.message || 'Publish task failed' + return res + } + } + + async publish(task: PublishTask): Promise { + try { + this.logger.log(`publish: Starting to process task ID: ${task.id}`) + const medias = await this.postMediaContainerService.getContainers( + task.id, + ) + if (!medias || medias.length === 0) { + return { + status: PublishStatus.FAILED, + message: 'Media not found for the task', + noRetry: true, + } + } + const unProcessedMedias = medias.filter( + media => media.status !== PostMediaStatus.FINISHED, + ) + this.logger.log( + `Found ${medias.length} media files for task ID: ${task.id}`, + ) + let processedCount = 0 + for (const media of unProcessedMedias) { + const mediaStatusInfo = await this.facebookService.getObjectInfo(task.accountId, media.taskId, 'status') + this.logger.log(`Media status for task ID ${task.id}, media ID ${media.taskId}: ${JSON.stringify(mediaStatusInfo)}`) + if (mediaStatusInfo.status.video_status === 'error') { + this.logger.error( + `Media processing failed for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + await this.postMediaContainerService.updateContainer(media.id, { + status: PostMediaStatus.FAILED, + }) + return { + status: PublishStatus.FAILED, + message: 'Media processing failed', + noRetry: true, + } + } + let mediaStatus = PostMediaStatus.CREATED + if ( + mediaStatusInfo.status.video_status === 'processing' + || mediaStatusInfo.status.video_status === 'encoded' + ) { + mediaStatus = PostMediaStatus.IN_PROGRESS + } + if (mediaStatusInfo.status.video_status === 'upload_complete') { + this.logger.log( + `Media processing finished for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + mediaStatus = PostMediaStatus.FINISHED + processedCount++ + } + await this.postMediaContainerService.updateContainer(media.id, { + status: mediaStatus, + }) + } + const isMediaCompleted = processedCount === unProcessedMedias.length + if (!isMediaCompleted) { + this.logger.warn(`Not all media files processed for task ID: ${task.id}. Processed: ${processedCount}, Total: ${medias.length}`) + throw new Error(`Media files are still processing. Processed: ${processedCount}, Total: ${medias.length}`) + } + this.logger.log(`All media files processed for task ID: ${task.id}`) + let publishRes + if (task.option?.facebook?.content_category === 'reel') { + publishRes = await this.facebookService.publishReel(task.accountId, { + upload_phase: 'finish', + video_state: 'published', + video_id: unProcessedMedias[0].taskId, + description: this.generatePostMessage(task), + }) + } + if (task.option?.facebook?.content_category === 'story') { + publishRes = await this.facebookService.publishVideoStory( + task.accountId, + { + upload_phase: 'finish', + video_state: 'published', + video_id: unProcessedMedias[0].taskId, + description: this.generatePostMessage(task), + }, + ) + } + this.logger.log( + `publish: Media container published for task ID: ${task.id}, response: ${JSON.stringify(publishRes)}`, + ) + if (!publishRes || !publishRes.success) { + this.logger.log( + `Failed to publish media container for task ID: ${task.id}`, + ) + return { + status: PublishStatus.FAILED, + message: '发布媒体容器失败', + noRetry: true, + } + } + let category = 'stories' + if (task.option?.facebook?.content_category === 'reel') { + category = 'reel' + } + const permalink = `https://www.facebook.com/${category}/${unProcessedMedias[0].taskId}` + + this.logger.log( + `Successfully published media container for task ID: ${task.id}`, + ) + await this.completePublishTask(task, unProcessedMedias[0].taskId, { + workLink: permalink, + }) + this.logger.log(`completed: Task ID ${task.id} processed successfully`) + return { + status: PublishStatus.PUBLISHED, + message: '所有媒体文件已处理完成', + } + } + catch (error) { + this.logger.error( + `Error processing task ID ${task.id}: ${error.message || error}`, + ) + return { + status: PublishStatus.FAILED, + message: `发布失败: ${error.message || error}`, + noRetry: true, + } + } + } + + override async pushPubTask(newData: PublishTask, attempts = 0): Promise { + await this.publishQueueOpen(newData.id) + const jobRes = await this.queueService.addPostPublishJob( + { + taskId: newData.id, + attempts: attempts++, // 进行次数 + jobId: newData.queueId, + }, + { + attempts: this.queueAttempts, + backoff: { + type: 'exponential', + delay: 20000, // 每次重试间隔 20 秒 + }, + removeOnComplete: true, + jobId: newData.queueId, // 确保任务id唯一,防止重复执行 + }, + ) + return jobRes.id === newData.queueId + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/instgram.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/instgram.service.ts new file mode 100644 index 000000000..e920674f0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/instgram.service.ts @@ -0,0 +1,450 @@ +import { Injectable, Logger } from '@nestjs/common' +import { InstagramService } from '../../../../core/plat/meta/instagram.service' +import { + PostCategory, + PostMediaStatus, + PostSubCategory, +} from '../../../../libs/database/schema/postMediaContainer.schema' +import { + PublishStatus, + PublishTask, +} from '../../../../libs/database/schema/publishTask.schema' +import { InstagramMediaType } from '../../../../libs/instagram/instagram.enum' +import { CreateMediaContainerRequest } from '../../../../libs/instagram/instagram.interfaces' +import { DoPubRes } from '../../common' +import { PublishBase } from '../publish.base' +import { PostMediaContainerService } from './container.service' +import { MetaPostPublisher, PublishMetaPostTask } from './meta.interface' + +@Injectable() +export class InstagramPublishService + extends PublishBase + implements MetaPostPublisher { + override queueName: string = 'instagram' + private readonly logger = new Logger(InstagramPublishService.name, { + timestamp: true, + }) + + constructor( + readonly instagramService: InstagramService, + private readonly postMediaContainerService: PostMediaContainerService, + ) { + super() + this.postMediaContainerService = postMediaContainerService + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + private generatePostMessage(publishTask: PublishTask): string { + if (!publishTask) { + return '' + } + if (publishTask.topics && publishTask.topics.length > 0) { + return `${publishTask.desc || ''} #${publishTask.topics.join(' #')}` + } + return publishTask.desc || '' + } + + private async createMediaContainer(publishTask: PublishTask, srcImgURL, mediaType = InstagramMediaType.IMAGE): Promise { + const createContainerReq: CreateMediaContainerRequest = { + media_type: mediaType, + image_url: srcImgURL, + caption: this.generatePostMessage(publishTask) || publishTask.title || '', + } + if (mediaType === InstagramMediaType.CAROUSEL) { + createContainerReq.is_carousel_item = true + } + const initUploadRes = await this.instagramService.createMediaContainer( + publishTask.accountId, + createContainerReq, + ) + try { + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'instagram', + taskId: initUploadRes.id, + status: PostMediaStatus.CREATED, + category: PostCategory.POST, + subCategory: PostSubCategory.PHOTO, + }) + } + catch (error) { + this.logger.error(`Create media record failed: ${error.message || error}`, error.stack) + throw new Error(`Create media record failed: ${error.message || error}`) + } + } + + async publishPost(publishTask: PublishTask): Promise { + let status = PublishStatus.FAILED + if (!publishTask.imgUrlList || publishTask.imgUrlList.length === 0) { + throw new Error('No image resources') + } + const mediaType = publishTask.imgUrlList.length === 1 ? InstagramMediaType.IMAGE : InstagramMediaType.CAROUSEL + for (const imgUrl of publishTask.imgUrlList) { + await this.createMediaContainer(publishTask, imgUrl, mediaType) + } + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + + status = PublishStatus.PUBLISHING + return status + } + + async publishReel(publishTask: PublishTask): Promise { + let status = PublishStatus.FAILED + if (!publishTask.videoUrl) { + throw new Error('No video resources') + } + + const createContainerReq: CreateMediaContainerRequest = { + video_url: publishTask.videoUrl, + media_type: InstagramMediaType.REELS, + caption: publishTask.desc || publishTask.title || '', + } + const initUploadRes = await this.instagramService.createMediaContainer( + publishTask.accountId, + createContainerReq, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'instagram', + taskId: initUploadRes.id, + status: PostMediaStatus.CREATED, + category: PostCategory.REELS, + subCategory: PostSubCategory.VIDEO, + }) + + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + status = PublishStatus.PUBLISHING + return status + } + + async publishVideoStory(publishTask: PublishTask): Promise { + let status = PublishStatus.FAILED + if (!publishTask.videoUrl) { + throw new Error('No video resources') + } + const createContainerReq: CreateMediaContainerRequest = { + video_url: publishTask.videoUrl, + media_type: InstagramMediaType.STORIES, + caption: this.generatePostMessage(publishTask) || publishTask.title || '', + } + const initUploadRes = await this.instagramService.createMediaContainer( + publishTask.accountId, + createContainerReq, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'instagram', + taskId: initUploadRes.id, + status: PostMediaStatus.CREATED, + category: PostCategory.STORY, + subCategory: PostSubCategory.VIDEO, + }) + + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + status = PublishStatus.PUBLISHING + return status + } + + async publishPhotoStory(publishTask: PublishTask): Promise { + let status = PublishStatus.FAILED + if (!publishTask.imgUrlList || publishTask.imgUrlList.length === 0) { + throw new Error('No image resources') + } + const createContainerReq: CreateMediaContainerRequest = { + media_type: InstagramMediaType.STORIES, + image_url: publishTask.imgUrlList[0], + } + const initUploadRes = await this.instagramService.createMediaContainer( + publishTask.accountId, + createContainerReq, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'instagram', + taskId: initUploadRes.id, + status: PostMediaStatus.CREATED, + category: PostCategory.STORY, + subCategory: PostSubCategory.PHOTO, + }) + + const task: PublishMetaPostTask = { + id: publishTask.id, + } + await this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + status = PublishStatus.PUBLISHING + return status + } + + async publish(task: PublishTask): Promise { + try { + this.logger.log(`publish: Starting to process task ID: ${task.id}`) + const medias = await this.postMediaContainerService.getContainers( + task.id, + ) + if (!medias || medias.length === 0) { + return { + status: PublishStatus.FAILED, + message: '没有找到媒体文件', + } + } + const unProcessedMedias = medias.filter( + media => media.status !== PostMediaStatus.FINISHED, + ) + this.logger.log( + `Found ${medias.length} media files for task ID: ${task.id}`, + ) + let processedCount = 0 + for (const media of unProcessedMedias) { + const mediaStatusInfo = await this.instagramService.getObjectInfo( + task.accountId, + media.taskId, + '', + ) + if (!mediaStatusInfo || !mediaStatusInfo.id) { + this.logger.error( + `Failed to get media status for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + continue + } + this.logger.log( + `Media status for task ID ${task.id}, media ID ${media.taskId}: ${JSON.stringify(mediaStatusInfo)}`, + ) + if (mediaStatusInfo.status === 'FAILED') { + this.logger.error( + `Media processing failed for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + await this.postMediaContainerService.updateContainer(media.id, { + status: PostMediaStatus.FAILED, + }) + return { + status: PublishStatus.FAILED, + message: '资源处理失败', + noRetry: true, + } + } + let mediaStatus = PostMediaStatus.CREATED + if (mediaStatusInfo.status === 'IN_PROGRESS') { + mediaStatus = PostMediaStatus.IN_PROGRESS + } + if (mediaStatusInfo.status === 'FINISHED') { + this.logger.log( + `Media processing finished for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + mediaStatus = PostMediaStatus.FINISHED + processedCount++ + } + await this.postMediaContainerService.updateContainer(media.id, { + status: mediaStatus, + }) + } + const isMediaCompleted = processedCount === unProcessedMedias.length + if (!isMediaCompleted) { + this.logger.warn( + `Not all media files processed for task ID: ${task.id}. Processed: ${processedCount}, Total: ${medias.length}`, + ) + return { + status: PublishStatus.PUBLISHING, + message: 'Waiting for media processing to complete', + } + } + this.logger.log(`All media files processed for task ID: ${task.id}`) + + const postCategory = unProcessedMedias[0].category + + let containerTypes = InstagramMediaType.IMAGE + if (postCategory === PostCategory.STORY) { + containerTypes = InstagramMediaType.STORIES + } + else if (postCategory === PostCategory.REELS) { + containerTypes = InstagramMediaType.REELS + } + + let containerId = medias[0].taskId + + if (postCategory === PostCategory.POST && medias.length > 1) { + containerTypes = InstagramMediaType.CAROUSEL + const containerIdList = medias.map(media => media.taskId) + const createContainerReq: CreateMediaContainerRequest = { + media_type: containerTypes, + children: containerIdList, + caption: this.generatePostMessage(task) || task.title || '', + } + const postContainer = await this.instagramService.createMediaContainer( + task.accountId, + createContainerReq, + ) + containerId = postContainer.id + } + this.logger.log(`Container ID for task ID ${task.id}: ${containerId}`) + const publishRes = await this.instagramService.publishMediaContainer( + task.accountId, + containerId, + ) + this.logger.log(`publish: Media container published for task ID: ${task.id}, response: ${JSON.stringify(publishRes)}`) + let permalink = '' + try { + const objectInfo = await this.instagramService.getObjectInfo( + task.accountId, + publishRes.id, + '', + 'permalink', + ) + this.logger.log( + `publish: Retrieved object info for task ID: ${task.id}, response: ${JSON.stringify(objectInfo)}`, + ) + if (objectInfo && objectInfo.permalink) { + permalink = objectInfo.permalink + } + } + catch (error) { + this.logger.error( + `Failed to get permalink for task ID: ${task.id}, error: ${error.message || error}`, + ) + } + this.logger.log( + `Successfully published media container for task ID: ${task.id}`, + ) + await this.completePublishTask(task, publishRes.id, { + workLink: permalink, + }) + this.logger.log(`completed: Task ID ${task.id} processed successfully`) + return { + status: PublishStatus.PUBLISHED, + message: 'Post published successfully', + } + } + catch (error) { + this.logger.error( + `Error processing task ID ${task.id}: ${error.message || error}`, + ) + return { + status: PublishStatus.FAILED, + message: error.message || 'Post publish failed', + noRetry: true, + } + } + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: PublishStatus.FAILED, + message: 'Publish task not found', + } + try { + const postCategory = publishTask.option?.instagram?.content_category + switch (postCategory) { + case 'post': + res.status = await this.publishPost(publishTask) + break + case 'reel': + res.status = await this.publishReel(publishTask) + break + case 'story': + if (publishTask.videoUrl) { + res.status = await this.publishVideoStory(publishTask) + } + else { + res.status = await this.publishPhotoStory(publishTask) + } + break + default: + res.message = 'Unknown content category' + return res + } + res.message = 'Post published successfully' + if (res.status === PublishStatus.PUBLISHING) { + res.message = 'Media processing, publishing in progress' + } + return res + } + catch (error) { + this.logger.error(`Publish error for task ID ${publishTask.id}: ${error.message || error}`, error.stack) + res.message = error.message || 'Post publish failed' + return res + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/linkedin.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/linkedin.service.ts new file mode 100644 index 000000000..a8295c005 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/linkedin.service.ts @@ -0,0 +1,208 @@ +import { Injectable, Logger } from '@nestjs/common' + +import { LinkedinService } from '../../../../core/plat/meta/linkedin.service' +import { + PublishStatus, + PublishTask, +} from '../../../../libs/database/schema/publishTask.schema' +import { + LinkedinShareCategory, + LinkedInShareRequest, + MemberNetworkVisibility, + ShareMedia, + ShareMediaCategory, + UploadRecipe, +} from '../../../../libs/linkedin/linkedin.interface' +import { DoPubRes } from '../../common' +import { PublishBase } from '../publish.base' + +@Injectable() +export class LinkedinPublishService extends PublishBase { + override queueName: string = 'linkedin' + + private readonly logger = new Logger(LinkedinPublishService.name, { + timestamp: true, + }) + + constructor( + readonly linkedinService: LinkedinService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + private generatePostMessage(publishTask: PublishTask): string { + if (!publishTask) { + return '' + } + if (publishTask.topics && publishTask.topics.length > 0) { + return `${publishTask.desc || ''} #${publishTask.topics.join(' #')}` + } + return publishTask.desc || '' + } + + private determinePostCategory( + publishTask: PublishTask, + ): LinkedinShareCategory { + const { imgUrlList, videoUrl } = publishTask + if (videoUrl) { + return LinkedinShareCategory.VIDEO + } + if (imgUrlList && imgUrlList.length > 0) { + return LinkedinShareCategory.IMAGE + } + return LinkedinShareCategory.TEXT + } + + private async publishTextPost(publishTask: PublishTask): Promise { + const createShareReq: LinkedInShareRequest = { + author: this.linkedinService.generateURN(publishTask.uid), + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { text: this.generatePostMessage(publishTask) || '' }, + shareMediaCategory: ShareMediaCategory.NONE, + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': + MemberNetworkVisibility.PUBLIC, + }, + } + this.logger.log(`Create share request: ${JSON.stringify(createShareReq)}`) + return await this.linkedinService.publish( + publishTask.accountId, + createShareReq, + ) + } + + private async publishImagePost(publishTask: PublishTask): Promise { + if (!publishTask.imgUrlList || publishTask.imgUrlList.length < 1) { + throw new Error('imgUrlList is empty') + } + const medias: ShareMedia[] = [] + for (const imgUrl of publishTask.imgUrlList) { + const resourceId = await this.linkedinService.uploadMedia( + publishTask.accountId, + imgUrl, + UploadRecipe.IMAGE, + ) + if (!resourceId) { + throw new Error(`upload image failed: ${imgUrl}`) + } + const media: ShareMedia = { + status: 'READY', + description: { text: '' }, + media: resourceId, + title: { text: '' }, + } + medias.push(media) + } + const createShareReq: LinkedInShareRequest = { + author: this.linkedinService.generateURN(publishTask.uid), + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { text: this.generatePostMessage(publishTask) || '' }, + shareMediaCategory: ShareMediaCategory.IMAGE, + media: medias, + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': + MemberNetworkVisibility.PUBLIC, + }, + } + return await this.linkedinService.publish( + publishTask.accountId, + createShareReq, + ) + } + + private async publishVideoPost(publishTask: PublishTask): Promise { + if (!publishTask.videoUrl) { + throw new Error('视频 URL 不能为空') + } + const resourceId = await this.linkedinService.uploadMedia( + publishTask.accountId, + publishTask.videoUrl, + UploadRecipe.VIDEO, + ) + if (!resourceId) { + throw new Error(`upload video failed: ${publishTask.videoUrl}`) + } + const createShareReq: LinkedInShareRequest = { + author: this.linkedinService.generateURN(publishTask.uid), + lifecycleState: 'PUBLISHED', + specificContent: { + 'com.linkedin.ugc.ShareContent': { + shareCommentary: { text: this.generatePostMessage(publishTask) || '' }, + shareMediaCategory: ShareMediaCategory.IMAGE, + media: [ + { + status: 'READY', + description: { text: '' }, + media: resourceId, + title: { text: '' }, + }, + ], + }, + }, + visibility: { + 'com.linkedin.ugc.MemberNetworkVisibility': + MemberNetworkVisibility.PUBLIC, + }, + } + return await this.linkedinService.publish( + publishTask.accountId, + createShareReq, + ) + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + try { + const category = this.determinePostCategory(publishTask) + let resourceId: string | undefined + if (category === LinkedinShareCategory.TEXT) { + resourceId = await this.publishTextPost(publishTask) + } + if (category === LinkedinShareCategory.IMAGE) { + resourceId = await this.publishImagePost(publishTask) + } + if (category === LinkedinShareCategory.VIDEO) { + resourceId = await this.publishVideoPost(publishTask) + } + if (resourceId) { + await this.completePublishTask(publishTask, resourceId, { + workLink: `https://www.linkedin.com/feed/update/${resourceId}`, + }) + res.status = PublishStatus.PUBLISHED + res.message = '发布成功' + return res + } + res.status = PublishStatus.FAILED + res.message = 'failed to get resourceId' + return res + } + catch (error) { + this.logger.error(`发布任务失败: ${error.message}`, error.stack) + res.message = `发布任务失败: ${error.message}` + return res + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.interface.ts new file mode 100644 index 000000000..a9613bcb0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.interface.ts @@ -0,0 +1,10 @@ +import { PublishTask } from '../../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../../common' + +export interface PublishMetaPostTask { + id: string +} + +export interface MetaPostPublisher { + publish: (task: PublishTask) => Promise +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.module.ts new file mode 100644 index 000000000..60f39d5e8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.module.ts @@ -0,0 +1,57 @@ +import { BullModule } from '@nestjs/bullmq' +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { MetaModule } from '../../../../core/plat/meta/meta.module' +import { TwitterModule } from '../../../../core/plat/twitter/twitter.module' +import { Account, AccountSchema } from '../../../../libs/database/schema/account.schema' +import { PostMediaContainer, PostMediaContainerSchema } from '../../../../libs/database/schema/postMediaContainer.schema' +import { PublishTask, PublishTaskSchema } from '../../../../libs/database/schema/publishTask.schema' +import { PostMediaContainerService } from './container.service' +import { FacebookPublishService } from './facebook.service' +import { InstagramPublishService } from './instgram.service' +import { LinkedinPublishService } from './linkedin.service' +import { MetaPublishService } from './meta.service' +import { ThreadsPublishService } from './threads.service' +import { TwitterPublishService } from './twitter.service' + +@Module({ + controllers: [], + providers: [ + MetaPublishService, + FacebookPublishService, + InstagramPublishService, + ThreadsPublishService, + PostMediaContainerService, + TwitterPublishService, + LinkedinPublishService, + ], + exports: [ + MetaPublishService, + FacebookPublishService, + InstagramPublishService, + ThreadsPublishService, + PostMediaContainerService, + TwitterPublishService, + LinkedinPublishService, + ], + imports: [ + MetaModule, + TwitterModule, + BullModule.registerQueue({ + name: 'post_publish', + }), + BullModule.registerQueue({ + name: 'post_media_task', + defaultJobOptions: { + delay: 60000, // 60 seconds + removeOnComplete: true, + }, + }), + MongooseModule.forFeature([ + { name: Account.name, schema: AccountSchema }, + { name: PublishTask.name, schema: PublishTaskSchema }, + { name: PostMediaContainer.name, schema: PostMediaContainerSchema }, + ]), + ], +}) +export class MetaPublishModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.service.ts new file mode 100644 index 000000000..81f8d00d3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/meta.service.ts @@ -0,0 +1,41 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { PublishStatus, PublishTask } from '../../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../../common' +import { FacebookPublishService } from './facebook.service' +import { InstagramPublishService } from './instgram.service' +import { MetaPostPublisher } from './meta.interface' +import { ThreadsPublishService } from './threads.service' +import { TwitterPublishService } from './twitter.service' + +@Injectable() +export class MetaPublishService { + private readonly publishSrvMap = new Map() + private readonly logger = new Logger(MetaPublishService.name) + constructor( + private readonly instagramPublishService: InstagramPublishService, + private readonly threadPublishService: ThreadsPublishService, + private readonly twitterPublishService: TwitterPublishService, + private readonly facebookPublishService: FacebookPublishService, + ) { + this.publishSrvMap.set(AccountType.INSTAGRAM, this.instagramPublishService) + this.publishSrvMap.set(AccountType.THREADS, this.threadPublishService) + this.publishSrvMap.set(AccountType.TWITTER, this.twitterPublishService) + this.publishSrvMap.set(AccountType.FACEBOOK, this.facebookPublishService) + } + + async publishPost(publishTask: PublishTask): Promise { + this.logger.log(`accountType: ${publishTask.accountType}, taskId: ${publishTask.id}`) + this.logger.log(this.publishSrvMap.keys()) + const service = this.publishSrvMap.get(publishTask.accountType) + if (!service) { + return { + status: PublishStatus.FAILED, + message: '未找到该平台的发布服务', + noRetry: true, + } + } + const res = await service.publish(publishTask) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/publish.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/publish.consumer.ts new file mode 100644 index 000000000..5bd470542 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/publish.consumer.ts @@ -0,0 +1,95 @@ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger, OnModuleDestroy } from '@nestjs/common' +import { QueueName } from '@yikart/aitoearn-queue' +import { Job } from 'bullmq' +import { PublishStatus } from '../../../../libs/database/schema/publishTask.schema' +import { PublishTaskService } from '../../publishTask.service' +import { MetaPublishService } from './meta.service' + +@Processor(QueueName.PostMediaTask, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class MetaPublishConsumer extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(MetaPublishConsumer.name) + constructor( + readonly publishTaskService: PublishTaskService, + readonly metaPublishService: MetaPublishService, + ) { + super() + } + + async process(job: Job<{ + taskId: string + attempts: number + }>): Promise { + this.logger.log(`[task-${job.data.taskId}] Processing Meta Post Publish Task: ${job.data.taskId}`) + try { + const publishTaskInfo = await this.publishTaskService.getPublishTaskInfo(job.data.taskId) + const publishTask = publishTaskInfo!.toObject() + if (!publishTask) { + this.logger.error(`[task-${job.data.taskId}] Publish task not found: ${job.data.taskId}`) + return + } + publishTask.publishTime = new Date() + this.logger.log(`[task-${job.data.taskId}] Publish task details: ${JSON.stringify(publishTask)}`) + const { status, message } = await this.metaPublishService.publishPost(publishTask) + + if (status !== PublishStatus.PUBLISHED) { + this.logger.error(`[task-${job.data.taskId}] Publish task failed: ${job.data.taskId}, Message: ${message}`) + throw new Error(message) + } + this.logger.debug(`[task-${job.data.taskId}] Publish task details: ${JSON.stringify(publishTask)}`) + return { status: 'success', message: 'Post published successfully' } + } + catch (error) { + this.logger.error(`[task-${job.data.taskId}] Error processing job ${job.id}: ${error.message}`, error.stack) + throw new Error(`[task-${job.data.taskId}] Job ${job.id} failed: ${error.message}`) + } + } + + @OnWorkerEvent('completed') + async onCompleted(job: Job<{ + taskId: string + attempts: number + jobId?: string + }>) { + const { taskId, attempts, jobId } = job.data + this.logger.log(`[task-${job.data.taskId}] Processing completed for job ${jobId}, taskId: ${taskId}, Attempts: ${attempts}`) + } + + @OnWorkerEvent('failed') + async onFailed(job: Job<{ + taskId: string + attempts: number + jobId?: string + }>, error: Error) { + const { taskId } = job.data + if (job.attemptsMade === job.opts.attempts) { + this.logger.error(`[task-${job.data.taskId}] Job ${taskId} failed after all attempts: ${error.message}`) + await this.publishTaskService.updatePublishTaskStatus(taskId, { + status: PublishStatus.FAILED, + errorMsg: error.message, + }) + this.logger.log(`[task-${job.data.taskId}] Publish task ${taskId} marked as failed after all attempts. and removed from queue.`) + return + } + this.logger.warn(`[task-${job.data.taskId}] Job ${taskId} failed, retrying... Attempts made: ${job.attemptsMade}`) + } + + @OnWorkerEvent('stalled') + onStalled(job: Job<{ + taskId: string + attempts: number + jobId?: string + }>) { + this.logger.error(`[task-${job.data.taskId}] Job stalled: ${job.id}`) + } + + async onModuleDestroy() { + this.logger.log('MetaPublishConsumer is being destroyed, closing worker...') + await this.worker.close() + this.logger.log('MetaPublishConsumer closed successfully') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/threads.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/threads.service.ts new file mode 100644 index 000000000..a543e338a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/threads.service.ts @@ -0,0 +1,344 @@ +import { Injectable, Logger } from '@nestjs/common' +import { ThreadsService } from '../../../../core/plat/meta/threads.service' +import { PostMediaStatus } from '../../../../libs/database/schema/postMediaContainer.schema' +import { + PublishStatus, + PublishTask, +} from '../../../../libs/database/schema/publishTask.schema' +import { ThreadsMediaType } from '../../../../libs/threads/threads.enum' +import { ThreadsContainerRequest } from '../../../../libs/threads/threads.interfaces' +import { DoPubRes } from '../../common' +import { PublishBase } from '../publish.base' +import { PostMediaContainerService } from './container.service' +import { MetaPostPublisher, PublishMetaPostTask } from './meta.interface' + +@Injectable() +export class ThreadsPublishService + extends PublishBase + implements MetaPostPublisher { + override queueName: string = 'threads' + private readonly logger = new Logger(ThreadsPublishService.name, { + timestamp: true, + }) + + constructor( + readonly threadsService: ThreadsService, + private readonly postMediaContainerService: PostMediaContainerService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + try { + const { imgUrlList, accountId, videoUrl } = publishTask + if (imgUrlList && imgUrlList.length > 0) { + const isCarouselItem = imgUrlList.length > 1 + for (const imgUrl of imgUrlList) { + const createContainerReq: ThreadsContainerRequest = { + media_type: 'IMAGE', + image_url: imgUrl, + text: publishTask.desc || '', + } + if ( + publishTask.option + && publishTask.option.threads + && publishTask.option.threads.location_id + ) { + createContainerReq.location_id + = publishTask.option.threads.location_id + } + if (publishTask.topics && publishTask.topics.length > 0) { + createContainerReq.topic_tag = publishTask.topics[0] + } + if (isCarouselItem) { + createContainerReq.is_carousel_item = true + } + const container = await this.threadsService.createItemContainer( + accountId, + createContainerReq, + ) + if (!container) { + res.message = '创建容器失败' + return res + } + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'threads', + taskId: container.id, + status: PostMediaStatus.CREATED, + }) + } + } + else if (videoUrl) { + const createContainerReq: ThreadsContainerRequest = { + media_type: 'VIDEO', + video_url: videoUrl, + text: publishTask.desc || '', + } + if (publishTask.topics && publishTask.topics.length > 0) { + createContainerReq.topic_tag = publishTask.topics[0] + } + if (imgUrlList && imgUrlList.length > 0) { + createContainerReq.is_carousel_item = true + } + if ( + publishTask.option + && publishTask.option.threads + && publishTask.option.threads.location_id + ) { + createContainerReq.location_id + = publishTask.option.threads.location_id + } + const container = await this.threadsService.createItemContainer( + accountId, + createContainerReq, + ) + if (!container) { + res.message = '创建视频容器失败' + return res + } + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'threads', + taskId: container.id, + status: PostMediaStatus.CREATED, + }) + } + else { + const createContainerReq: ThreadsContainerRequest = { + media_type: 'TEXT', + text: publishTask.desc || '', + } + if (publishTask.topics && publishTask.topics.length > 0) { + createContainerReq.topic_tag = publishTask.topics[0] + } + if ( + publishTask.option + && publishTask.option.threads + && publishTask.option.threads.location_id + ) { + createContainerReq.location_id + = publishTask.option.threads.location_id + } + const container = await this.threadsService.createItemContainer( + accountId, + createContainerReq, + ) + if (!container) { + res.message = '创建视频容器失败' + return res + } + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'threads', + taskId: container.id, + status: PostMediaStatus.CREATED, + }) + } + const task: PublishMetaPostTask = { + id: publishTask.id, + } + const result = this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 30, + backoff: { + type: 'fixed', + delay: 10000, + }, + removeOnComplete: true, + removeOnFail: true, + }, + ) + + this.logger.log(`Media task added to queue: ${result}`) + res.status = PublishStatus.PUBLISHING + res.message = '发布中' + return res + } + catch (error) { + res.message = `发布失败: ${error.message}` + return res + } + } + + async publish(task: PublishTask): Promise { + const containers = await this.postMediaContainerService.getContainers( + task.id, + ) + if (!containers || containers.length === 0) { + return { + status: PublishStatus.FAILED, + message: '没有找到媒体文件', + noRetry: true, + } + } + const unProcessedContainers = containers.filter( + media => media.status !== PostMediaStatus.FINISHED, + ) + this.logger.log( + `Found ${containers.length} media files for task ID: ${task.id}`, + ) + let processedCount = 0 + for (const media of unProcessedContainers) { + const mediaStatusInfo = await this.threadsService.getObjectInfo( + task.accountId, + media.taskId, + '', + ) + if (!mediaStatusInfo || !mediaStatusInfo.id) { + this.logger.error( + `Failed to get media status for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + continue + } + this.logger.log( + `Media status for task ID ${task.id}, media ID ${media.taskId}: ${JSON.stringify(mediaStatusInfo)}`, + ) + if (mediaStatusInfo.status === 'FAILED') { + this.logger.error( + `Media processing failed for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + await this.postMediaContainerService.updateContainer(media.id, { + status: PostMediaStatus.FAILED, + }) + return { + status: PublishStatus.FAILED, + message: '资源处理失败', + noRetry: true, + } + } + let mediaStatus = PostMediaStatus.CREATED + if (mediaStatusInfo.status === 'IN_PROGRESS') { + mediaStatus = PostMediaStatus.IN_PROGRESS + } + if (mediaStatusInfo.status === 'FINISHED') { + this.logger.log( + `Media processing finished for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + mediaStatus = PostMediaStatus.FINISHED + processedCount++ + } + await this.postMediaContainerService.updateContainer(media.id, { + status: mediaStatus, + }) + } + const isMediaCompleted = processedCount === unProcessedContainers.length + if (!isMediaCompleted) { + this.logger.warn( + `Not all media files processed for task ID: ${task.id}. Processed: ${processedCount}, Total: ${containers.length}`, + ) + return { + status: PublishStatus.PUBLISHING, + message: '媒体文件处理中', + } + } + this.logger.log(`All media files processed for task ID: ${task.id}`) + let containerTypes = ThreadsMediaType.VIDEO + let containerId = containers[0].taskId + + if (containers.length > 1) { + containerTypes = ThreadsMediaType.CAROUSEL + const containerIdList = containers.map(media => media.taskId) + const createContainerReq: ThreadsContainerRequest = { + media_type: containerTypes, + children: containerIdList, + text: task.desc || '', + } + if (task.topics && task.topics.length > 0) { + createContainerReq.topic_tag = task.topics[0] + } + if ( + task.option + && task.option.threads + && task.option.threads.location_id + ) { + createContainerReq.location_id = task.option.threads.location_id + } + const postContainer = await this.threadsService.createItemContainer( + task.accountId, + createContainerReq, + ) + if (!postContainer || !postContainer.id) { + this.logger.error( + `Failed to create media container for task ID: ${task.id}`, + ) + return { + status: PublishStatus.FAILED, + message: '创建媒体容器失败', + noRetry: true, + } + } + containerId = postContainer.id + } + this.logger.log(`Container ID for task ID ${task.id}: ${containerId}`) + const publishRes = await this.threadsService.publishPost( + task.accountId, + containerId, + ) + if (!publishRes || !publishRes.id) { + this.logger.error( + `Failed to publish media container for task ID: ${task.id}`, + ) + return { + status: PublishStatus.FAILED, + message: '发布媒体容器失败', + noRetry: true, + } + } + this.logger.log( + `Successfully published media container for task ID: ${task.id}`, + ) + let permalink = '' + try { + const objectInfo = await this.threadsService.getObjectInfo( + task.accountId, + publishRes.id, + '', + 'permalink', + ) + if (objectInfo && objectInfo.permalink) { + permalink = objectInfo.permalink + } + } + catch (error) { + this.logger.error( + `Failed to get object info for published post: ${error.message}`, + error.stack, + ) + } + await this.completePublishTask(task, publishRes.id, { + workLink: permalink, + }) + this.logger.log(`completed: Task ID ${task.id} processed successfully`) + return { + status: PublishStatus.PUBLISHED, + message: '所有媒体文件已处理完成', + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/twitter.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/twitter.service.ts new file mode 100644 index 000000000..b7e7a231f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/meta/twitter.service.ts @@ -0,0 +1,419 @@ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountType } from '@yikart/aitoearn-server-client' +import { Model } from 'mongoose' +import { + chunkedDownloadFile, + fileUrlToBlob, + getFileTypeFromUrl, + getRemoteFileSize, +} from '../../../../common' +import { TwitterService } from '../../../../core/plat/twitter/twitter.service' +import { Account } from '../../../../libs/database/schema/account.schema' +import { + PostMediaStatus, + PostSubCategory, +} from '../../../../libs/database/schema/postMediaContainer.schema' +import { + PublishStatus, + PublishTask, +} from '../../../../libs/database/schema/publishTask.schema' +import { XMediaCategory, XMediaType } from '../../../../libs/twitter/twitter.enum' +import { + PostMedia, + XChunkedMediaUploadRequest, + XCreatePostRequest, + XMediaUploadInitRequest, +} from '../../../../libs/twitter/twitter.interfaces' +import { DoPubRes } from '../../common' +import { PublishBase } from '../publish.base' +import { PostMediaContainerService } from './container.service' +import { MetaPostPublisher, PublishMetaPostTask } from './meta.interface' + +@Injectable() +export class TwitterPublishService + extends PublishBase + implements MetaPostPublisher { + override queueName: string = AccountType.TWITTER + + private readonly logger = new Logger(TwitterPublishService.name, { + timestamp: true, + }) + + constructor( + @InjectModel(Account.name) + readonly AccountModel: Model, + readonly twitterService: TwitterService, + private readonly postMediaContainerService: PostMediaContainerService, + ) { + super() + this.postMediaContainerService = postMediaContainerService + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + private generatePostMessage(publishTask: PublishTask): string { + if (!publishTask) { + return '' + } + if (publishTask.topics && publishTask.topics.length > 0) { + return `${publishTask.desc || ''} #${publishTask.topics.join(' #')}` + } + return publishTask.desc || '' + } + + async publishPlainTextPost(task: PublishTask): Promise { + const post: XCreatePostRequest = { + text: this.generatePostMessage(task) || '', + } + const createPostRes = await this.twitterService.createPost( + task.accountId, + post, + ) + this.logger.log( + `publish: Media container published for task ID: ${task.id}, response: ${JSON.stringify(createPostRes)}`, + ) + if (!createPostRes || !createPostRes.data.id) { + this.logger.log( + `Failed to publish media container for task ID: ${task.id}`, + ) + return { + status: PublishStatus.FAILED, + message: 'Failed to publish text post', + noRetry: true, + } + } + let permalink = `https://x.com/${task.uid}/status/${createPostRes.data.id}` + const account = await this.AccountModel.findOne({ _id: task.accountId }) + if (account && account.account) { + permalink = `https://x.com/${account.account}/status/${createPostRes.data.id}` + } + + this.logger.log( + `Successfully published media container for task ID: ${task.id}`, + ) + await this.completePublishTask(task, createPostRes.data.id, { + workLink: permalink, + }) + this.logger.log(`completed: Task ID ${task.id} processed successfully`) + return { + status: PublishStatus.PUBLISHED, + message: '所有媒体文件已处理完成', + } + } + + private isPlainTextPost(publishTask: PublishTask): boolean { + const { imgUrlList, videoUrl } = publishTask + if (!imgUrlList && !videoUrl) { + return true + } + if (imgUrlList && imgUrlList.length === 0 && !videoUrl) { + return true + } + return false + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + + const { imgUrlList, accountId, videoUrl } = publishTask + if (this.isPlainTextPost(publishTask)) { + return this.publishPlainTextPost(publishTask) + } + if (imgUrlList) { + for (const imgUrl of imgUrlList) { + const imgBlob = await fileUrlToBlob(imgUrl) + if (!imgBlob) { + res.message = '图片下载失败' + return res + } + const fileName = getFileTypeFromUrl(imgUrl) + const ext = fileName.split('.').pop()?.toLowerCase() + const mimeType = ext === 'jpg' ? 'image/jpeg' : `image/${ext}` + const initUploadReq: XMediaUploadInitRequest = { + media_type: mimeType as XMediaType, + total_bytes: imgBlob.blob.size, + media_category: XMediaCategory.TWEET_IMAGE, + shared: false, + } + const initUploadRes = await this.twitterService.initMediaUpload( + accountId, + initUploadReq, + ) + if (!initUploadRes || !initUploadRes.data.id) { + res.message = '图片初始化上传失败' + return res + } + this.logger.log(`initUploadRes: ${JSON.stringify(initUploadRes)}`) + const uploadReq: XChunkedMediaUploadRequest = { + media: await imgBlob.blob, + media_id: initUploadRes.data.id, + segment_index: 0, + } + + const updateRes = await this.twitterService.chunkedMediaUploadRequest( + accountId, + uploadReq, + ) + if (!updateRes || !updateRes.data.expires_at) { + res.message = '图片分片上传失败' + return res + } + this.logger.log( + `chunkedMediaUploadRequest: ${JSON.stringify(updateRes)}`, + ) + const finalizeUploadRes = await this.twitterService.finalizeMediaUpload( + accountId, + initUploadRes.data.id, + ) + if (!finalizeUploadRes || !finalizeUploadRes.data.id) { + res.message = '确认图片上传失败' + return res + } + this.logger.log( + `finalizeMediaUpload: ${JSON.stringify(finalizeUploadRes)}`, + ) + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'twitter', + taskId: initUploadRes.data.id, + status: PostMediaStatus.FINISHED, + subCategory: PostSubCategory.PHOTO, + }) + } + } + + if (videoUrl) { + const fileName = getFileTypeFromUrl(videoUrl, true) + const ext = fileName.split('.').pop()?.toLowerCase() + const mimeType = ext === 'mp4' ? 'video/mp4' : `video/${ext}` + + const contentLength = await getRemoteFileSize(videoUrl) + if (!contentLength) { + res.message = '视频信息解析失败' + return res + } + const initUploadReq: XMediaUploadInitRequest = { + media_type: mimeType as XMediaType, + total_bytes: contentLength, + media_category: XMediaCategory.TWEET_VIDEO, + shared: false, + } + + const initUploadRes = await this.twitterService.initMediaUpload( + accountId, + initUploadReq, + ) + if (!initUploadRes || !initUploadRes.data.id) { + res.message = '视频初始化上传失败' + return res + } + const chunkSize = 4 * 1024 * 1024 // 4MB + const totalChunks = Math.ceil(contentLength / chunkSize) + for (let sequenceNum = 0; sequenceNum < totalChunks; sequenceNum++) { + const start = sequenceNum * chunkSize + const end = Math.min(start + chunkSize - 1, contentLength - 1) + const range: [number, number] = [start, end] + const fileSegment = await chunkedDownloadFile(videoUrl, range) + if (!fileSegment) { + res.message = '视频分片下载失败' + return res + } + this.logger.log( + `chunked upload, Size: ${fileSegment.length}, Range: ${start}-${end}, Sequence: ${sequenceNum}`, + ) + const uploadReq: XChunkedMediaUploadRequest = { + media: new Blob([fileSegment]), + media_id: initUploadRes.data.id, + segment_index: sequenceNum, + } + const upload = await this.twitterService.chunkedMediaUploadRequest( + accountId, + uploadReq, + ) + this.logger.log(`chunkedMediaUploadRequest: ${JSON.stringify(upload)}`) + if (!upload || !upload.data.expires_at) { + res.message = '视频分片上传失败' + return res + } + } + const finalizeUploadRes = await this.twitterService.finalizeMediaUpload( + accountId, + initUploadRes.data.id, + ) + if (!finalizeUploadRes || !finalizeUploadRes.data.id) { + res.message = '确认视频上传完成失败' + return res + } + await this.postMediaContainerService.createMetaPostMedia({ + accountId: publishTask.accountId, + publishId: publishTask.id, + userId: publishTask.userId, + platform: 'twitter', + taskId: initUploadRes.data.id, + status: PostMediaStatus.CREATED, + subCategory: PostSubCategory.VIDEO, + }) + } + const task: PublishMetaPostTask = { + id: publishTask.id, + } + this.queueService.addPostMediaTaskJob( + { + taskId: task.id, + attempts: 0, + }, + { + attempts: 0, + removeOnComplete: true, + removeOnFail: true, + }, + ) + + res.status = PublishStatus.PUBLISHING + res.message = '发布中' + return res + } + + async publish(task: PublishTask): Promise { + try { + this.logger.log(`publish: Starting to process task ID: ${task.id}`) + const medias = await this.postMediaContainerService.getContainers( + task.id, + ) + if (!medias || medias.length === 0) { + return { + status: PublishStatus.FAILED, + message: '没有找到媒体文件', + noRetry: true, + } + } + const unProcessedMedias = medias.filter( + media => media.status !== PostMediaStatus.FINISHED, + ) + this.logger.log( + `Found ${medias.length} media files for task ID: ${task.id}`, + ) + let processedCount = 0 + for (const media of unProcessedMedias) { + const mediaStatusInfo = await this.twitterService.getMediaUploadStatus( + task.accountId, + media.taskId, + ) + if (!mediaStatusInfo || !mediaStatusInfo.data.processing_info.state) { + this.logger.error( + `Failed to get media status for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + continue + } + this.logger.log( + `Media status for task ID ${task.id}, media ID ${media.taskId}: ${JSON.stringify(mediaStatusInfo)}`, + ) + if (mediaStatusInfo.data.processing_info.state === 'failed') { + this.logger.error( + `Media processing failed for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + await this.postMediaContainerService.updateContainer(media.id, { + status: PostMediaStatus.FAILED, + }) + return { + status: PublishStatus.FAILED, + message: '资源处理失败', + noRetry: true, + } + } + let mediaStatus = PostMediaStatus.CREATED + if (mediaStatusInfo.data.processing_info.state === 'in_progress') { + mediaStatus = PostMediaStatus.IN_PROGRESS + } + if (mediaStatusInfo.data.processing_info.state === 'succeeded') { + this.logger.log( + `Media processing finished for task ID: ${task.id}, media ID: ${media.taskId}`, + ) + mediaStatus = PostMediaStatus.FINISHED + processedCount++ + } + await this.postMediaContainerService.updateContainer(media.id, { + status: mediaStatus, + }) + } + const isMediaCompleted = processedCount === unProcessedMedias.length + if (!isMediaCompleted) { + this.logger.warn( + `Not all media files processed for task ID: ${task.id}. Processed: ${processedCount}, Total: ${medias.length}`, + ) + return { + status: PublishStatus.PUBLISHING, + message: '媒体文件处理中', + } + } + this.logger.log(`All media files processed for task ID: ${task.id}`) + const postMedia: PostMedia = { + media_ids: medias.map(media => media.taskId), + } + const post: XCreatePostRequest = { + text: this.generatePostMessage(task) || '', + media: postMedia, + } + const createPostRes = await this.twitterService.createPost( + task.accountId, + post, + ) + this.logger.log( + `publish: Media container published for task ID: ${task.id}, response: ${JSON.stringify(createPostRes)}`, + ) + if (!createPostRes || !createPostRes.data.id) { + this.logger.log( + `Failed to publish media container for task ID: ${task.id}`, + ) + return { + status: PublishStatus.FAILED, + message: '发布媒体容器失败', + noRetry: true, + } + } + let permalink = `https://x.com/${task.uid}/status/${createPostRes.data.id}` + const account = await this.AccountModel.findOne({ _id: task.accountId }) + if (account && account.account) { + permalink = `https://x.com/${account.account}/status/${createPostRes.data.id}` + } + + this.logger.log( + `Successfully published media container for task ID: ${task.id}`, + ) + await this.completePublishTask(task, createPostRes.data.id, { + workLink: permalink, + }) + this.logger.log(`completed: Task ID ${task.id} processed successfully`) + return { + status: PublishStatus.PUBLISHED, + message: '所有媒体文件已处理完成', + } + } + catch (error) { + this.logger.error( + `Error processing task ID ${task.id}: ${error.message || error}`, + ) + return { + status: PublishStatus.FAILED, + message: `发布失败: ${error.message || error}`, + noRetry: true, + } + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/pinterestPub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/pinterestPub.service.ts new file mode 100644 index 000000000..ccff3540c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/pinterestPub.service.ts @@ -0,0 +1,126 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import * as _ from 'lodash' +import { PinterestService } from '../../../core/plat/pinterest/pinterest.service' +import { PublishStatus, PublishTask } from '../../../libs/database/schema/publishTask.schema' +import { SourceType } from '../../../libs/pinterest/common' +import { DoPubRes } from '../common' +import { PublishBase } from './publish.base' + +@Injectable() +export class PinterestPubService extends PublishBase { + override queueName: string = AccountType.PINTEREST + private readonly logger = new Logger(PinterestPubService.name) + + constructor( + readonly pinterestService: PinterestService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + doPub(publishTask: PublishTask): Promise { + return new Promise(async (resolve) => { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + // 判断是创建图片还是上传视频pin + const { imgUrlList, videoUrl } = publishTask + + if (videoUrl) + return this.handleVideoUpload(publishTask, resolve, res) + if (imgUrlList) + return this.handlePicUpload(publishTask, resolve, res) + }) + } + + async handlePicUpload(publishTask: PublishTask, resolve: any, res: DoPubRes) { + const { desc: description, accountId, imgUrlList, title, option } = publishTask + if (_.isEmpty(option) || _.isEmpty(option?.pinterest)) { + return resolve({ + message: '必须要上传board_id', + status: PublishStatus.FAILED, + }) + } + const { boardId: board_id } = option.pinterest + const body: any + = { + accountId, + board_id, + description, + title, + media_source: + { + source_type: SourceType.image_url, + url: _.first(imgUrlList), + }, + } + const data = await this.pinterestService.createPin(body) + if (_.isEmpty(data)) { + res.message = '稿件发布失败' + return resolve(res) + } + await this.completePublishTask(publishTask, data.data.id, { + workLink: `https://www.pinterest.com/pin/${data.data.id}/`, + }) + res.message = '发布成功' + res.status = PublishStatus.PUBLISHED + resolve(res) + } + + async handleVideoUpload(publishTask: PublishTask, resolve: any, res: DoPubRes) { + const { desc: description, accountId, coverUrl, title, videoUrl, option } = publishTask + if (_.isEmpty(option) || _.isEmpty(option?.pinterest) || !_.isString(videoUrl)) { + return resolve({ + message: '必须要上传board_id', + status: PublishStatus.FAILED, + }) + } + // 上传视频获取到视频id + const result = await this.pinterestService.uploadVideo(videoUrl, accountId) + if (_.isEmpty(result) || _.isEmpty(result?.data) || !_.isString(result.data.media_id)) { + return resolve({ + message: '上传视频失败', + status: PublishStatus.FAILED, + }) + } + const { media_id } = result.data + const { boardId: board_id } = option.pinterest + const body: any + = { + accountId, + board_id, + description, + title, + media_source: + { + source_type: SourceType.video_id, + media_id, + cover_image_url: coverUrl, + }, + } + const data = await this.pinterestService.createPin(body) + if (_.isEmpty(data)) { + res.message = '稿件发布失败' + return resolve(res) + } + res.message = '发布成功' + res.status = PublishStatus.PUBLISHED + await this.completePublishTask(publishTask, data.data.id, { + workLink: `https://www.pinterest.com/pin/${data.data.id}/`, + }) + resolve(res) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/publish.base.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/publish.base.ts new file mode 100644 index 000000000..c85823bda --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/publish.base.ts @@ -0,0 +1,110 @@ +import { Inject, Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { InjectModel } from '@nestjs/mongoose' + +import { QueueService } from '@yikart/aitoearn-queue' +import { PublishRecord } from '@yikart/aitoearn-server-client' +import { Model } from 'mongoose' +import { + PublishStatus, + PublishTask, +} from '../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../common' + +@Injectable() +export abstract class PublishBase { + protected readonly queueName: string = 'unknown' + protected readonly queueAttempts: number = 3 // 并发数量 + protected readonly queueDelay: number = 5 // 延迟时间 + + @Inject(QueueService) + protected readonly queueService: QueueService + + @Inject(EventEmitter2) + protected readonly eventEmitter: EventEmitter2 + + @InjectModel(PublishTask.name) + protected readonly publishTaskModel: Model + + constructor() {} + + // 检测授权是否失效 + abstract checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> + + // 进行发布(给队列调用) + abstract doPub(publishTask: PublishTask): Promise + + protected async createPublishTask(newData: Partial) { + return await this.publishTaskModel.create(newData) + } + + protected async createPublishRecord(newData: Partial) { + newData.publishTime = newData.publishTime || new Date() + this.eventEmitter.emit('publishRecord.create', newData) + } + + /** + * 推送任务 + * @param newData + * @param doNum + * @returns + */ + async pushPubTask(task: PublishTask, attempts = 0): Promise { + await this.publishQueueOpen(task.id) + const jobRes = await this.queueService.addPostPublishJob( + { + taskId: task.id, + attempts: attempts++, + jobId: task.queueId, + }, + { + attempts: this.queueAttempts, + backoff: { + type: 'exponential', + delay: this.queueDelay, // 每次重试间隔 5 秒 + }, + removeOnComplete: true, + removeOnFail: true, + jobId: task.queueId, // 确保任务id唯一,防止重复执行 + }, + ) + return jobRes.id === task.queueId + } + + // 将数据的队列状态改为 true + async publishQueueOpen(id: string) { + this.publishTaskModel.updateOne({ _id: id }, { inQueue: true }) + } + + // 获取发布任务信息 + protected async getPublishTaskInfo(id: string) { + return this.publishTaskModel.findOne({ _id: id }) + } + + // 删除发布任务 + private async delPublishTask(id: string) { + return this.publishTaskModel.deleteOne({ _id: id }) + } + + // 完成发布任务 + protected async completePublishTask( + newData: PublishTask, + dataId: string, + data?: { + // 作品链接 + workLink: string + dataOption?: Record + }, + ) { + newData.status = PublishStatus.PUBLISHED + await this.createPublishRecord({ + ...newData, + ...(data || {}), + dataId, + }) + await this.delPublishTask(newData.id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/tiktokPub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/tiktokPub.service.ts new file mode 100644 index 000000000..be42321f1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/tiktokPub.service.ts @@ -0,0 +1,248 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AitoearnServerClientService } from '@yikart/aitoearn-server-client' +import { chunkedDownloadFile, getFileTypeFromUrl, getRemoteFileSize } from '../../../common' +import { config } from '../../../config' +import { PublishRecordService } from '../../../core/account/publishRecord.service' +import { PostInfoDto, VideoFileUploadSourceDto, VideoPullUrlSourceDto } from '../../../core/plat/tiktok/dto/tiktok.dto' +import { TiktokService } from '../../../core/plat/tiktok/tiktok.service' +import { PublishStatus, PublishTask } from '../../../libs/database/schema/publishTask.schema' +import { TiktokPrivacyLevel, TiktokSourceType } from '../../../libs/tiktok/tiktok.enum' +import { DoPubRes } from '../common' +import { TiktokWebhookDto } from '../dto/tiktok.webhook.dto' +import { PublishBase } from './publish.base' + +@Injectable() +export class TiktokPubService extends PublishBase { + override queueName: string = 'tiktok' + private readonly logger = new Logger(TiktokPubService.name, { + timestamp: true, + }) + + constructor( + readonly tiktokService: TiktokService, + readonly publishRecordService: PublishRecordService, + private readonly serverClient: AitoearnServerClientService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + private generatePostMessage(publishTask: PublishTask): string { + if (!publishTask) { + return '' + } + if (publishTask.topics && publishTask.topics.length > 0) { + return `${publishTask.desc || ''} #${publishTask.topics.join(' #')}` + } + return publishTask.desc || '' + } + + private isDevEnv(): boolean { + return config.tiktok.redirectUri.includes('dev') + } + + async publishVideoViaUpload(publishTask: PublishTask): Promise { + const { accountId, videoUrl } = publishTask + if (!videoUrl) { + throw new Error('video url is required') + } + const fileName = getFileTypeFromUrl(videoUrl, true) + const ext = fileName.split('.').pop()?.toLowerCase() + const mimeType = ext === 'mp4' ? 'video/mp4' : `video/${ext}` + + const contentLength = await getRemoteFileSize(videoUrl) + if (!contentLength) { + throw new Error('get video meta failed') + } + let chunkSize = 6 * 1024 * 1024 // 6MB + if (contentLength < chunkSize) { + chunkSize = contentLength + } + const privacy_level = publishTask.option?.tiktok?.privacy_level ? publishTask.option.tiktok.privacy_level as TiktokPrivacyLevel : TiktokPrivacyLevel.PUBLIC + const postInfo: PostInfoDto = { + title: publishTask.title || publishTask.desc || '', + privacy_level, + brand_content_toggle: publishTask.option?.tiktok?.brand_content_toggle || false, + brand_organic_toggle: publishTask.option?.tiktok?.brand_organic_toggle || false, + disable_comment: publishTask.option?.tiktok?.disable_comment || false, + disable_duet: publishTask.option?.tiktok?.disable_duet || false, + disable_stitch: publishTask.option?.tiktok?.disable_stitch || false, + } + + const sourceInfo: VideoFileUploadSourceDto = { + source: TiktokSourceType.FILE_UPLOAD, + video_size: contentLength, + chunk_size: chunkSize, + total_chunk_count: Math.floor(contentLength / chunkSize), + } + + this.logger.log(`init video upload: accountId: ${accountId}, postInfo: ${JSON.stringify(postInfo)}, sourceInfo: ${JSON.stringify(sourceInfo)}`) + const initUploadRes = await this.tiktokService.initVideoPublish( + accountId, + postInfo, + sourceInfo, + ) + this.logger.log(`视频初始化上传结果: ${JSON.stringify(initUploadRes)}`) + if (!initUploadRes || !initUploadRes.upload_url) { + throw new Error('init upload video failed') + } + const totalParts = Math.floor(contentLength / chunkSize) + const chunks: [number, number][] = [] + let start = 0 + for (let partNumber = 0; partNumber < totalParts - 1; partNumber++) { + const end = start + chunkSize - 1 + chunks.push([start, end]) + start += chunkSize + } + chunks.push([start, contentLength - 1]) + + for (const chunk of chunks) { + const videoBlob = await chunkedDownloadFile(videoUrl, chunk) + if (!videoBlob) { + throw new Error('download video chunk failed') + } + + const uploadResult = await this.tiktokService.chunkedUploadVideoFile( + initUploadRes.upload_url, + videoBlob, + chunk, + contentLength, + mimeType, + ) + this.logger.log(`视频分片上传完成: ${JSON.stringify(uploadResult)}`) + } + return initUploadRes.publish_id + } + + async publishVideoViaURL(publishTask: PublishTask): Promise { + try { + const { accountId, videoUrl } = publishTask + if (!videoUrl) { + throw new Error('video url is required') + } + const privacyLevel = this.isDevEnv() ? TiktokPrivacyLevel.SELF_ONLY : TiktokPrivacyLevel.PUBLIC + const postInfo: PostInfoDto = { + title: this.generatePostMessage(publishTask), + privacy_level: privacyLevel, + brand_content_toggle: false, + brand_organic_toggle: false, + } + + const sourceInfo: VideoPullUrlSourceDto = { + source: TiktokSourceType.PULL_FROM_URL, + video_url: videoUrl, + } + + const publishRes = await this.tiktokService.initVideoPublish( + accountId, + postInfo, + sourceInfo, + ) + this.logger.log(`视频发布结果: ${JSON.stringify(publishRes)}`) + if (!publishRes || !publishRes.publish_id) { + throw new Error('publish video failed') + } + return publishRes.publish_id + } + catch (error) { + this.logger.error('publishVideoViaUrl error', error) + throw error + } + } + + async doPub(publishTask: PublishTask): Promise { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + + try { + const { videoUrl } = publishTask + if (videoUrl) { + const publishId = await this.publishVideoViaUpload(publishTask) + publishTask.status = PublishStatus.PUBLISHING + await this.createPublishRecord({ + ...publishTask, + dataId: publishId, + publishTime: new Date(), + }) + res.status = PublishStatus.PUBLISHING + res.message = '发布任务已提交,等待处理' + return res + } + } + catch (error) { + this.logger.error(`Publish TikTok video failed: ${error.message}`, error.stack) + res.status = PublishStatus.FAILED + res.message = error.message || 'Publish TikTok video failed with unknown error' + return res + } + return res + } + + async handleTiktokPostWebhook(dto: TiktokWebhookDto): Promise { + try { + const content = JSON.parse(dto.content) + if (!dto.event.startsWith('post.publish')) { + this.logger.error(`未知 TikTok 事件类型: ${dto.event}`) + return + } + const publishId = content?.publish_id + if (!publishId) { + this.logger.error(`invalid publish_id in webhook: ${JSON.stringify(content)}`) + return + } + const publishRecord = await this.serverClient.publishing.getPublishRecordByDataId(publishId, dto.user_openid) + if (!publishRecord) { + this.logger.error(`未找到发布记录: ${publishId}, 用户: ${dto.user_openid}`) + return + } + switch (dto.event) { + case 'post.publish.failed': + this.logger.error(`发布失败: ${JSON.stringify(content)}`) + publishRecord.status = PublishStatus.FAILED + publishRecord.errorMsg = content.reason || '发布失败' + await this.publishRecordService.updatePublishRecordStatus(publishRecord._id, publishRecord.status, publishRecord.errorMsg) + await this.publishTaskModel.updateOne({ queueId: publishRecord.queueId }, { + status: PublishStatus.FAILED, + errorMsg: content.reason || '发布失败', + }) + break + case 'post.publish.complete': + this.logger.log(`发布成功: ${JSON.stringify(content)}`) + publishRecord.status = PublishStatus.PUBLISHED + await this.publishRecordService.updatePublishRecordStatus(publishRecord._id, publishRecord.status, publishRecord.errorMsg) + this.publishTaskModel.deleteOne({ queueId: publishRecord.queueId }) + break + case 'post.publish.inbox_delivered': + this.logger.log(`发布已送达: ${JSON.stringify(content)}`) + publishRecord.status = PublishStatus.PUBLISHED + await this.publishRecordService.updatePublishRecordStatus(publishRecord._id, publishRecord.status, publishRecord.errorMsg) + this.publishTaskModel.deleteOne({ queueId: publishRecord.queueId }) + break + case 'post.publish.publicly_available': + publishRecord.status = PublishStatus.PUBLISHED + publishRecord.dataId = content.post_id || publishRecord.dataId + await this.publishRecordService.updatePublishRecordStatus(publishRecord._id, publishRecord.status, publishRecord.errorMsg) + break + default: + this.logger.error(`未知事件类型: ${dto.event}`) + break + } + } + catch (error) { + this.logger.error(`处理 TikTok webhook 失败: ${error.message}`, error.stack) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/wxGzhPub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/wxGzhPub.service.ts new file mode 100644 index 000000000..58b72b18b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/wxGzhPub.service.ts @@ -0,0 +1,139 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { WxGzhService } from '../../../core/plat/wxPlat/wxGzh.service' +import { + PublishTask, + PublishType, +} from '../../../libs/database/schema/publishTask.schema' +import { MediaType } from '../../../libs/wxGzh/common' +import { DoPubRes } from '../common' +import { PublishBase } from './publish.base' + +@Injectable() +export class WxGzhPubService extends PublishBase { + logger = new Logger(WxGzhPubService.name) + override queueName: string = AccountType.WxGzh + + constructor( + readonly wxGzhService: WxGzhService, + ) { + super() + } + + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + return this.wxGzhService.checkAuth(accountId) + } + + async doPub(publishTask: PublishTask) { + // 开始任务 + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + + const { coverUrl, accountId, imgUrlList, title, desc, type, option } + = publishTask + if (!imgUrlList || imgUrlList.length === 0) { + res.message = '图片列表不能为空' + return res + } + if (!title) { + res.message = '标题不能为空' + return res + } + if (!desc) { + res.message = '描述不能为空' + return res + } + if (type !== PublishType.ARTICLE) { + res.message = '公众号只有文章发布' + return res + } + + // 上传图片 + const wxGzhImgMaterialIdList: { + image_media_id: string + }[] = [] + + // 封面 + if (coverUrl) { + this.logger.log('正在上传封面...') + const coverUrlRes = await this.wxGzhService.addMaterial( + accountId, + MediaType.image, + coverUrl, + ) + this.logger.log(coverUrlRes) + if (!coverUrlRes || coverUrlRes.errcode) { + res.message = '封面上传失败' + return res + } + wxGzhImgMaterialIdList.push({ + image_media_id: coverUrlRes.media_id, + }) + this.logger.log('封面上传成功:', coverUrlRes) + } + + // 图片 + for (const imgUrl of imgUrlList) { + this.logger.log('正在上传图片...') + const imgUrlRes = await this.wxGzhService.addMaterial( + accountId, + MediaType.image, + imgUrl, + ) + if (!imgUrlRes || imgUrlRes.errcode) { + res.message = '图片上传失败' + return res + } + wxGzhImgMaterialIdList.push({ image_media_id: imgUrlRes.media_id }) + this.logger.log('图片上传成功:', imgUrlRes) + } + + // 创建草稿 + const draftAddRes = await this.wxGzhService.draftAdd(accountId, { + article_type: 'newspic', + title, + content: desc, + image_info: { + image_list: wxGzhImgMaterialIdList, + }, + ...option?.wxGzh, + }) + + if (draftAddRes.errcode) { + res.message = `稿件草稿创建失败: ${draftAddRes.errmsg}` + return res + } + + // 发布 + const freePublishRes = await this.wxGzhService.freePublish( + accountId, + draftAddRes.media_id, + ) + + if (freePublishRes.errcode) { + res.message = `发布任务创建失败: ${freePublishRes.errmsg}` + return res + } + + // 完成发布任务 + void this.completePublishTask(publishTask, freePublishRes.publish_id, { + // workLink: `https://mp.weixin.qq.com/s/${freePublishRes.publish_id}`, + workLink: '', + dataOption: { + publish_id: freePublishRes.publish_id, + msg_data_id: freePublishRes.msg_data_id, + article_id: '', + }, + }) + res.message = '发布成功' + res.status = 1 + + // 进行发布 + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/youtubePub.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/youtubePub.service.ts new file mode 100644 index 000000000..3a6bd072a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/plat/youtubePub.service.ts @@ -0,0 +1,163 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/aitoearn-server-client' +import { getFileSizeFromUrl, streamDownloadAndUpload } from '../../../common' +import { YoutubeService } from '../../../core/plat/youtube/youtube.service' +import { + PublishStatus, + PublishTask, +} from '../../../libs/database/schema/publishTask.schema' +import { DoPubRes } from '../common' +import { PublishBase } from './publish.base' + +@Injectable() +export class YoutubePubService extends PublishBase { + override queueName: string = AccountType.YOUTUBE + private readonly logger = new Logger(YoutubePubService.name) + + constructor( + readonly youtubeService: YoutubeService, + ) { + super() + } + + // TODO: 校验账户授权状态 + async checkAuth(accountId: string): Promise<{ + status: 0 | 1 + timeout?: number // 秒 + }> { + this.logger.log(`checkAuth: ${accountId}`) + return { + status: 1, + timeout: 10000, + } + } + + doPub(publishTask: PublishTask): Promise { + return new Promise(async (resolve) => { + const res: DoPubRes = { + status: -1, + message: '任务不存在', + } + + // const { coverUrl, accountId, videoUrl } = publishTask + + const TaskInfo = { + coverUrl: publishTask.coverUrl, + accountId: publishTask.accountId, + videoUrl: publishTask.videoUrl, + title: publishTask.title, + desc: publishTask.desc, + tag: publishTask.topics.join(','), + categoryId: publishTask?.option?.youtube?.categoryId, + privacyStatus: publishTask?.option?.youtube?.privacyStatus, + } + + if (!TaskInfo.videoUrl) { + res.message = '视频不存在' + res.noRetry = true + return resolve(res) + } + + this.logger.log('TaskInfo:----', TaskInfo) + // const fileName = this.fileToolsService.getFileTypeFromUrl(TaskInfo.videoUrl) + const contentLength = await getFileSizeFromUrl(TaskInfo.videoUrl) + this.logger.log('视频大小:----', contentLength) + if (!contentLength) { + res.message = '视频大小获取失败' + return resolve(res) + } + this.logger.log('正在分片上传...') + // 视频分片上传初始化 + const videoUpToken = await this.youtubeService.initVideoUpload( + TaskInfo.accountId, + TaskInfo.title || '', + TaskInfo.desc || '', + TaskInfo.tag?.split(',') || [], + publishTask?.option?.youtube?.license || 'youtube', + TaskInfo.categoryId || '22', + TaskInfo.privacyStatus || 'private', + publishTask?.option?.youtube?.notifySubscribers || false, + publishTask?.option?.youtube?.embeddable || false, + publishTask?.option?.youtube?.selfDeclaredMadeForKids || false, + Number(contentLength), + ) + if (!videoUpToken) { + res.message = '视频初始化失败' + return resolve(res) + } + + // 视频URL分片上传 + void streamDownloadAndUpload( + TaskInfo.videoUrl, + async (upData: Buffer, partNumber: number) => { + this.logger.log(`分片:${partNumber}`) + await this.youtubeService.uploadVideoPart( + TaskInfo.accountId, + upData, + videoUpToken, + partNumber, + ) + }, + async () => { + this.logger.log('发布...') + // 发布 + const resourceId = await this.youtubeService.videoComplete( + TaskInfo.accountId, + videoUpToken, + Number(contentLength), + ) + if (!resourceId) { + res.message = '稿件发布失败' + return resolve(res) + } + + if (!resourceId) { + res.message = '稿件发布失败' + return resolve(res) + } + + // 封面上传 + // 有封面 + // if (TaskInfo.coverUrl) { + // this.logger.log('正在上传封面...') + // const urlBase64 = await this.fileToolsService.fileUrlToBase64(TaskInfo.coverUrl) + // const coverRes = await this.youtubeService.uploadThumbnails( + // TaskInfo.accountId, + // resourceId, + // urlBase64, + // ) + // if (!coverRes) { + // res.message = '封面上传失败' + // return resolve(res) + // } + + // this.logger.log('封面上传成功:', coverRes) + + // publishTask.coverUrl = coverRes + // } + + // 设置封面 + // await this.youtubeService.uploadThumbnails( + // TaskInfo.accountId, + // resourceId, + // TaskInfo.coverUrl, + // ) + + // 完成发布任务 + void this.completePublishTask(publishTask, resourceId, { + workLink: `https://www.youtube.com/watch?v=${resourceId}`, + }) + res.message = '发布成功' + res.status = 1 + + resolve(res) + }, + ).catch((e) => { + resolve({ + message: e.message, + status: PublishStatus.FAILED, + }) + }) + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/post-publish.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/post-publish.consumer.ts new file mode 100644 index 000000000..bfd3d430b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/post-publish.consumer.ts @@ -0,0 +1,122 @@ +/* + * @Author: nevin + * @Date: 2024-07-03 15:16:12 + * @LastEditTime: 2025-02-10 17:18:50 + * @LastEditors: nevin + * @Description: 视频发布队列 + */ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger, OnModuleDestroy } from '@nestjs/common' +import { QueueName } from '@yikart/aitoearn-queue' +import { Job } from 'bullmq' +import { PublishStatus } from '../../libs/database/schema/publishTask.schema' +import { PublishTaskService } from './publishTask.service' + +@Processor(QueueName.PostPublish, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class PostPublishConsumer extends WorkerHost implements OnModuleDestroy { + private readonly logger = new Logger(PostPublishConsumer.name) + constructor(readonly publishTaskService: PublishTaskService) { + super() + } + + // Todo: doPub timeout control + async process( + job: Job<{ + taskId: string + attempts: number + jobId?: string + timeout?: number + }>, + ): Promise { + const { taskId, attempts, timeout } = job.data + this.logger.log(`[task-${taskId}] Processing Publish Task, data: ${JSON.stringify(job.data)}, Attempts: ${attempts}`) + + try { + const taskDoc = await this.publishTaskService.getPublishTaskInfo(taskId) + if (!taskDoc) { + this.logger.error(`[task-${taskId}] Publish task not found: ${taskId}`) + return + } + + const taskInfo = taskDoc.toObject() + if (timeout && timeout > 0) { + this.logger.log(`[task-${taskId}] Publish task timeout set to ${timeout}ms`) + + const publishPromise = this.publishTaskService.doPub(taskInfo) + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`[task-${taskId}] Publish task timeout after ${timeout}ms`)) + }, timeout) + }) + + const result = await Promise.race([publishPromise, timeoutPromise]) + + if (result.status === PublishStatus.FAILED) { + this.logger.error(`[task-${taskId}] Publish task failed: ${taskId}, Message: ${result.message}`) + throw new Error(result.message) + } + + this.logger.log(`[task-${taskId}] Publish task completed successfully with timeout control`) + return result + } + else { + const result = await this.publishTaskService.doPub(taskInfo) + + if (result.status === PublishStatus.FAILED) { + this.logger.error(`[task-${taskId}] Publish task failed: ${taskId}, Message: ${result.message}`) + throw new Error(result.message) + } + + this.logger.log(`[task-${taskId}] Publish task completed successfully`) + return result + } + } + catch (error) { + this.logger.error(`[task-${taskId}] Error processing job ${job.id}: ${error.message}`, error.stack) + throw new Error(`[task-${taskId}] Job ${job.id} failed: ${error.message}`) + } + } + + @OnWorkerEvent('completed') + async onCompleted(job: Job<{ + taskId: string + attempts: number + jobId?: string + }>) { + const { taskId, attempts, jobId } = job.data + this.logger.log(`[task-${taskId}] Processing completed for job ${jobId}, taskId: ${taskId}, Attempts: ${attempts}`) + } + + @OnWorkerEvent('failed') + async onFailed(job: Job<{ + taskId: string + attempts: number + jobId?: string + }>, error: Error) { + if (job.attemptsMade === job.opts.attempts) { + this.logger.error(`[task-${job.data.taskId}] Job ${job.id} failed after all attempts: ${error.message}`) + await this.publishTaskService.updatePublishTaskStatus(job.data.taskId, { + status: PublishStatus.FAILED, + errorMsg: error.message, + }) + this.logger.log(`[task-${job.data.taskId}] Publish task ${job.data.taskId} marked as failed after all attempts.`) + return + } + this.logger.warn(`[task-${job.data.taskId}] Job ${job.data.taskId} failed, retrying... Attempts made: ${job.attemptsMade}`) + } + + @OnWorkerEvent('stalled') + onStalled(job: Job) { + this.logger.error(`Job ${job.id}] is stalled, data ${job.data}`) + } + + async onModuleDestroy() { + this.logger.log('PostPublishConsumer is being destroyed, closing worker...') + await this.worker.close() + this.logger.log('PostPublishConsumer closed successfully') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publish.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publish.module.ts new file mode 100644 index 000000000..eaabf0996 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publish.module.ts @@ -0,0 +1,72 @@ +import { forwardRef, Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { PinterestModule } from '../../core/plat/pinterest/pinterest.module' +import { PinterestPubService } from '../../core/publish/plat/pinterestPub.service' +import { Account, AccountSchema } from '../../libs/database/schema/account.schema' +import { + PostMediaContainer, + PostMediaContainerSchema, +} from '../../libs/database/schema/postMediaContainer.schema' +import { + PublishTask, + PublishTaskSchema, +} from '../../libs/database/schema/publishTask.schema' +import { BilibiliModule } from '../plat/bilibili/bilibili.module' +import { KwaiModule } from '../plat/kwai/kwai.module' +import { MetaModule } from '../plat/meta/meta.module' +import { TiktokModule } from '../plat/tiktok/tiktok.module' +import { TwitterModule } from '../plat/twitter/twitter.module' +import { WxPlatModule } from '../plat/wxPlat/wxPlat.module' +import { YoutubeModule } from '../plat/youtube/youtube.module' +import { BilibiliPubService } from './plat/bilibiliPub.service' +import { kwaiPubService } from './plat/kwaiPub.service' +import { FacebookPublishService } from './plat/meta/facebook.service' +import { InstagramPublishService } from './plat/meta/instgram.service' +import { LinkedinPublishService } from './plat/meta/linkedin.service' +import { MetaPublishModule } from './plat/meta/meta.module' +import { MetaPublishConsumer } from './plat/meta/publish.consumer' +import { ThreadsPublishService } from './plat/meta/threads.service' +import { TiktokPubService } from './plat/tiktokPub.service' +import { WxGzhPubService } from './plat/wxGzhPub.service' +import { YoutubePubService } from './plat/youtubePub.service' +import { PostPublishConsumer } from './post-publish.consumer' +import { PublishTaskController } from './publishTask.controller' +import { PublishTaskService } from './publishTask.service' + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Account.name, schema: AccountSchema }, + { name: PublishTask.name, schema: PublishTaskSchema }, + { name: PostMediaContainer.name, schema: PostMediaContainerSchema }, + ]), + BilibiliModule, + PinterestModule, + KwaiModule, + YoutubeModule, + forwardRef(() => WxPlatModule), + MetaModule, + TiktokModule, + MetaPublishModule, + TwitterModule, + PinterestModule, + ], + providers: [ + PublishTaskService, + PostPublishConsumer, + BilibiliPubService, + PinterestPubService, + kwaiPubService, + YoutubePubService, + WxGzhPubService, + FacebookPublishService, + InstagramPublishService, + ThreadsPublishService, + TiktokPubService, + MetaPublishConsumer, + LinkedinPublishService, + ], + controllers: [PublishTaskController], + exports: [PublishTaskService], +}) +export class PublishModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.controller.ts new file mode 100644 index 000000000..aec94363d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.controller.ts @@ -0,0 +1,111 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 发布 + */ +import { Body, Controller, Logger, Post } from '@nestjs/common' +import { AppException } from '@yikart/common' +import { ExceptionCode } from '../../common/enums/exception-code.enum' +import { AccountService } from '../account/account.service' +import { + CreatePublishDto, + DeletePublishTaskDto, + NowPubTaskDto, + PublishRecordListFilterDto, + UpPublishTaskTimeDto, +} from './dto/publish.dto' +import { TiktokWebhookDto, TiktokWebhookSchema } from './dto/tiktok.webhook.dto' +import { PublishTaskService } from './publishTask.service' + +@Controller() +export class PublishTaskController { + private readonly logger = new Logger(PublishTaskController.name) + constructor( + private readonly publishTaskService: PublishTaskService, + private readonly accountService: AccountService, + ) {} + + // 创建发布任务 + // @NatsMessagePattern('plat.publish.create') + @Post('plat/publish/create') + async createPub(@Body() data: CreatePublishDto) { + return await this.publishTaskService.createPublishingTask(data) + } + + // 更新任务时间 + // @NatsMessagePattern('publish.task.changeTime') + @Post('publish/task/changeTime') + async changeTaskTime(@Body() data: UpPublishTaskTimeDto) { + data.publishTime = new Date(data.publishTime) + const res = await this.publishTaskService.updatePublishTaskTime( + data.id, + data.publishTime, + data.userId, + ) + return res + } + + // 删除任务 + // @NatsMessagePattern('publish.task.delete') + @Post('publish/task/delete') + async deletePublishTask(@Body() data: DeletePublishTaskDto) { + return await this.publishTaskService.deletePublishTaskById( + data.id, + data.userId, + ) + } + + // 立即发布任务 + // @NatsMessagePattern('publish.task.run') + @Post('publish/task/run') + async nowPubTask(@Body() data: NowPubTaskDto) { + const info = await this.publishTaskService.getPublishTaskInfo(data.id) + if (!info) + throw new AppException(ExceptionCode.Failed, '未发现任务') + + const { status, message, noRetry } + = await this.publishTaskService.doPub(info) + this.logger.log(`立即发布任务${info.id}执行结果:${status} ${message} ${noRetry}`) + this.logger.log(`发布任务${info.id}执行结果:${status} ${message} ${noRetry}`) + + return status + } + + // @NatsMessagePattern('publish.tiktok.post.webhook') + @Post('publish/tiktok/post/webhook') + async handleTiktokWebhook(@Body() data: any) { + this.logger.log(`Received TikTok webhook: ${JSON.stringify(data)}`) + try { + const dto: TiktokWebhookDto = TiktokWebhookSchema.parse(data) + await this.publishTaskService.handleTiktokPostWebhook(dto) + } + catch (error) { + this.logger.error(`Error handling TikTok webhook: ${error.message}`, error.stack) + throw new AppException(ExceptionCode.Failed, '处理 TikTok webhook 失败') + } + return { status: 'success', message: 'Webhook processed' } + } + + // @NatsMessagePattern('channel.publishTask.list') + @Post('channel/publishTask/list') + async getPublishTaskList(@Body() data: PublishRecordListFilterDto) { + const res = await this.publishTaskService.getPublishRecordList(data) + return res + } + + // @NatsMessagePattern('channel.publishTask.detail') + @Post('channel/publishTask/detail') + async getPublishingTaskDetail(@Body() data: { flowId: string, userId: string }) { + const res = await this.publishTaskService.getPublishTaskInfoWithFlowId(data.flowId, data.userId) + return res + } + + // @NatsMessagePattern('channel.publishing.task.detail') + @Post('channel/publishing/task/detail') + async getPublishTaskInfoWithUserId(@Body() data: { taskId: string, userId: string }) { + const res = await this.publishTaskService.getPublishTaskInfoWithUserId(data.taskId, data.userId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.service.ts new file mode 100644 index 000000000..de08ba61b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/publish/publishTask.service.ts @@ -0,0 +1,395 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Cron } from '@nestjs/schedule' +import { QueueService } from '@yikart/aitoearn-queue' +import { AccountType } from '@yikart/aitoearn-server-client' +import { AppException } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { v4 as uuidv4 } from 'uuid' +import { ExceptionCode } from '../../common/enums/exception-code.enum' +import { IMMEDIATE_PUSH_THRESHOLD_MS, PUSH_SCHEDULED_TASK_CRON_EXPRESSION, PUSH_SCHEDULED_TASK_QUERY_WINDOW_MS } from '../../core/publish/constant' +import { PinterestPubService } from '../../core/publish/plat/pinterestPub.service' +import { PublishStatus, PublishTask } from '../../libs/database/schema/publishTask.schema' +import { AccountService } from '../account/account.service' +import { NewPulData, PlatPulOption } from './common' +import { CreatePublishDto, PublishRecordListFilterDto } from './dto/publish.dto' +import { TiktokWebhookDto } from './dto/tiktok.webhook.dto' +import { BilibiliPubService } from './plat/bilibiliPub.service' +import { kwaiPubService } from './plat/kwaiPub.service' +import { FacebookPublishService } from './plat/meta/facebook.service' +import { InstagramPublishService } from './plat/meta/instgram.service' +import { LinkedinPublishService } from './plat/meta/linkedin.service' +import { ThreadsPublishService } from './plat/meta/threads.service' +import { TwitterPublishService } from './plat/meta/twitter.service' +import { PublishBase } from './plat/publish.base' +import { TiktokPubService } from './plat/tiktokPub.service' +import { WxGzhPubService } from './plat/wxGzhPub.service' +import { YoutubePubService } from './plat/youtubePub.service' + +@Injectable() +export class PublishTaskService implements OnModuleDestroy { + private readonly publishServiceMap = new Map() + private readonly logger = new Logger(PublishTaskService.name) + + constructor( + private readonly accountService: AccountService, + @InjectModel(PublishTask.name) + private readonly publishTaskModel: Model, + private readonly bilibiliPubService: BilibiliPubService, + private readonly kwaiPubService: kwaiPubService, + private readonly youtubePubService: YoutubePubService, + private readonly wxGzhPubService: WxGzhPubService, + private readonly facebookPubService: FacebookPublishService, + private readonly instagramPubService: InstagramPublishService, + private readonly threadPubService: ThreadsPublishService, + private readonly tiktokPubService: TiktokPubService, + private readonly twitterPubService: TwitterPublishService, + private readonly pinterestPubService: PinterestPubService, + private readonly linkedInPubService: LinkedinPublishService, + private readonly queueService: QueueService, + ) { + this.publishServiceMap.set(AccountType.BILIBILI, this.bilibiliPubService) + this.publishServiceMap.set(AccountType.KWAI, this.kwaiPubService) + this.publishServiceMap.set(AccountType.YOUTUBE, this.youtubePubService) + this.publishServiceMap.set(AccountType.FACEBOOK, this.facebookPubService) + this.publishServiceMap.set(AccountType.INSTAGRAM, this.instagramPubService) + this.publishServiceMap.set(AccountType.THREADS, this.threadPubService) + this.publishServiceMap.set(AccountType.WxGzh, this.wxGzhPubService) + this.publishServiceMap.set(AccountType.TIKTOK, this.tiktokPubService) + this.publishServiceMap.set(AccountType.TWITTER, this.twitterPubService) + this.publishServiceMap.set(AccountType.PINTEREST, this.pinterestPubService) + this.publishServiceMap.set(AccountType.LINKEDIN, this.linkedInPubService) + } + + async createPub(taskInfo: NewPulData) { + const { publishTime, accountType } = taskInfo + taskInfo['queueId'] = `publish:${accountType}:${uuidv4()}` + const newTask = await this.publishTaskModel.create(taskInfo) + + const publishImmediately = publishTime.getTime() < (Date.now() + IMMEDIATE_PUSH_THRESHOLD_MS) + if (!publishImmediately) { + this.logger.log(`Publish task ${newTask.id} created, scheduled for ${publishTime.toISOString()}`) + return newTask + } + + const res = await this.pushPubTask(newTask) + if (!res) + throw new AppException(1, `task publish failed, accountType: ${accountType}`) + this.logger.log(`Publish task ${newTask.id} created and pushed to queue immediately`) + return newTask + } + + /** + * 推送任务-定时器使用 + * @param publishTask + * @returns + */ + async pushPubTask(publishTask: PublishTask) { + const pubService = this.publishServiceMap.get(publishTask.accountType) + if (!pubService) + throw new AppException(1, `publish service for ${publishTask.accountType} not found`) + + const res = await pubService.pushPubTask(publishTask) + if (!res) + throw new AppException(1, `task publish failed, accountType: ${publishTask.accountType}`) + return res + } + + /** + * 给任务调用的方法 + * @param publishTask 任务 + * @returns 推送结果 + */ + async doPub(publishTask: PublishTask) { + const pubService = this.publishServiceMap.get(publishTask.accountType) + this.logger.log(`Processing Publish Task: ${publishTask.id}, Account Type: ${publishTask.accountType}`) + if (!pubService) { + return { + status: PublishStatus.FAILED, + message: `publish service for ${publishTask.accountType} not found`, + noRetry: true, + } + } + await this.updatePublishTaskStatus(publishTask.id, { + status: PublishStatus.PUBLISHING, + }) + const res = await pubService.doPub(publishTask) + + // 更新任务状态 + this.updatePublishTaskStatus(publishTask.id, { + status: res.status, + }) + return res + } + + /** + * 获取时间段内发布任务列表 + * @param start + * @param end + * @returns + */ + async getPublishTaskListByTime( + start: Date, + end: Date, + ): Promise { + const filters: RootFilterQuery = { + publishTime: { $gte: start, $lte: end }, + status: PublishStatus.WaitingForPublish, + } + const list = await this.publishTaskModel.find(filters).sort({ + publishTime: 1, + }) + + return list + } + + /** + * 获取发布记录列表 + * @param query + * @returns + */ + async getPublishRecordList( + query: PublishRecordListFilterDto, + ): Promise { + const filters: RootFilterQuery = { + userId: query.userId, + ...(query.accountId !== undefined && { accountId: query.accountId }), + ...(query.accountType !== undefined && { + accountType: query.accountType, + }), + ...(query.status !== undefined && { + status: query.status, + }), + ...(query.type !== undefined && { type: query.type }), + ...(query.time !== undefined + && query.time.length === 2 && { + publishTime: { $gte: query.time[0], $lte: query.time[1] }, + }), + ...(query.uid !== undefined && { uid: query.uid }), + } + const db = this.publishTaskModel.find(filters).sort({ + createdAt: -1, + }) + const list = await db.exec() + + return list + } + + /** + * 获取时间段内发布任务列表 + * @param start + * @param end + * @returns + */ + async getPublishTaskListByFlowId( + flowId: string, + ): Promise { + const filters: RootFilterQuery = { + flowId, + } + const list = await this.publishTaskModel.find(filters).sort({ + publishTime: 1, + }) + return list + } + + /** + * 更新任务状态 + * @param id + * @param newData + * @returns + */ + async updatePublishTaskStatus( + id: string, + newData: { + errorMsg?: string + status: PublishStatus + }, + ): Promise { + const res = await this.publishTaskModel.updateOne({ _id: id }, newData) + return res.modifiedCount > 0 + } + + // 查询任务是否在队列中,如果在,删除队列中的任务 + async deleteQueueTask(queueId: string) { + const job = await this.queueService.getPostPublishJob(queueId) + if (job) { + const state = await job.getState() + if (state === 'waiting' || state === 'delayed') { + // 如果任务处于等待或延迟状态,则移除它 + await job.remove() + this.logger.log(`任务 ${queueId} 已从队列中移除`) + } + else if (state === 'active') { + // 如果任务正在执行中,不建议删除 + throw new AppException(1, '任务正在执行中,无法删除') + } + } + } + + // 删除任务 + async deletePublishTaskById(id: string, userId: string): Promise { + // 获取数据 + const task = await this.publishTaskModel.findById(id).exec() + if (!task) { + throw new AppException(1, '任务不存在') + } + if (task.inQueue && !!task.queueId) { + await this.deleteQueueTask(task.queueId) + } + + // 删除数据库数据 + const res = await this.publishTaskModel.deleteOne({ _id: id, userId }) + return res.deletedCount > 0 + } + + // 更新任务时间 + async updatePublishTaskTime(id: string, publishTime: Date, userId: string) { + const task = await this.publishTaskModel.findById(id).exec() + if (!task) { + throw new AppException(1, '任务不存在') + } + + const res = await this.publishTaskModel.updateOne( + { _id: id, userId }, + { publishTime }, + ) + if (task.inQueue && !!task.queueId) { + await this.deleteQueueTask(task.queueId) + } + return res.modifiedCount > 0 + } + + // 获取发布任务信息 + async getPublishTaskInfo(id: string) { + return this.publishTaskModel.findOne({ _id: id }) + } + + async getPublishTaskInfoWithFlowId(flowId: string, userId: string) { + return this.publishTaskModel.findOne({ flowId, userId }) + } + + async getPublishTaskInfoWithUserId(id: string, userId: string) { + return this.publishTaskModel.findOne({ _id: id, userId }) + } + + // 立即发布任务 + async publishTaskNow(id: string) { + const taskDoc = await this.getPublishTaskInfo(id) + const taskInfo = taskDoc!.toObject() + if (!taskInfo) { + throw new AppException(1, 'publish task not found') + } + if (taskInfo.status !== PublishStatus.WaitingForPublish) { + throw new AppException(1, 'task has been published or is in progress') + } + const pubService = this.publishServiceMap.get(taskInfo.accountType) + + taskInfo.publishTime = new Date() + await this.publishTaskModel.updateOne({ _id: id }, taskInfo) + await pubService?.pushPubTask(taskInfo) + return true + } + + async handleTiktokPostWebhook(data: TiktokWebhookDto) { + return this.tiktokPubService.handleTiktokPostWebhook(data) + } + + // push scheduled publish tasks + @Cron(PUSH_SCHEDULED_TASK_CRON_EXPRESSION, { waitForCompletion: true }) + async pushScheduledPubTasks() { + this.logger.log(`Start pushing scheduled publish tasks, current time: ${new Date().toISOString()}`) + try { + const start = new Date() + const end = new Date(start.getTime() + PUSH_SCHEDULED_TASK_QUERY_WINDOW_MS) + + const tasks = await this.getPublishTaskListByTime(start, end) + if (tasks.length === 0) { + this.logger.log(`Pushing scheduled publish tasks from ${start.toISOString()} to ${end.toISOString()}: No scheduled publish tasks found`) + this.logger.log(`Pushing scheduled publish tasks completed, current time: ${new Date().toISOString()}`) + return + } + this.logger.log( + `Pushing scheduled publish tasks from ${start.toISOString()} to ${end.toISOString()}, found ${tasks.length} tasks`, + ) + + for (const task of tasks) { + await this.pushPubTask(task) + } + this.logger.log(`Pushing scheduled publish tasks completed, current time: ${new Date().toISOString()}`) + } + catch (error) { + this.logger.error(`Error pushing scheduled publish tasks: ${error.message}`, error.stack) + this.logger.log(`Pushing scheduled publish tasks completed, current time: ${new Date().toISOString()}`) + } + } + + private determineMetaPostCategory(data: CreatePublishDto) { + switch (data.accountType) { + case AccountType.FACEBOOK: + if (!data.option || !data.option.facebook || !data.option.facebook.content_category) { + data.option = { + ...data.option, + facebook: { + ...data.option?.facebook, + content_category: 'post', + }, + } + } + break + case AccountType.INSTAGRAM: { + if (!data.option || !data.option.instagram || !data.option.instagram.content_category) { + let category = 'post' + if (data.videoUrl) { + category = 'reel' + } + data.option = { + ...data.option, + instagram: { + ...data.option?.instagram, + content_category: category, + }, + } + } + break + } + } + } + + async createPublishingTask(data: CreatePublishDto) { + try { + const metaPlatforms = [AccountType.FACEBOOK, AccountType.INSTAGRAM] + if (metaPlatforms.includes(data.accountType)) { + this.determineMetaPostCategory(data) + } + this.logger.log(`Creating publish task with data: ${JSON.stringify(data)}`) + data.publishTime = new Date(data.publishTime) + + const accountInfo = await this.accountService.getAccountInfo( + data.accountId, + ) + if (!accountInfo) + throw new AppException(ExceptionCode.Failed, '账号信息获取失败') + + const res = await this.createPub({ + ...data, + uid: accountInfo.uid, + userId: accountInfo.userId, + inQueue: false, + queueId: '', + publishTime: data.publishTime, + }) + return res + } + catch (e) { + this.logger.error(e) + return new AppException(ExceptionCode.Failed, e) + } + } + + async onModuleDestroy() { + this.logger.log('Module is being destroyed, closing publish queue...') + await this.queueService.closePostPublishQueue() + this.logger.log('Publish queue closed successfully') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/dto/skKey.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/dto/skKey.dto.ts new file mode 100644 index 000000000..9b197a471 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/dto/skKey.dto.ts @@ -0,0 +1,39 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const CreateSkKeySchema = z.object({ + userId: z.string().describe('用户ID'), + desc: z.string().describe('描述').optional(), +}) +export class CreateSkKeyDto extends createZodDto(CreateSkKeySchema) {} + +export const SkKeyKeySchema = z.object({ + key: z.string().describe('key'), +}) +export class SkKeyKeyDto extends createZodDto(SkKeyKeySchema) {} + +export const UpSkKeyInfoSchema = z.object({ + key: z.string().describe('key'), + desc: z.string().describe('描述'), +}) +export class UpSkKeyInfoDto extends createZodDto(UpSkKeyInfoSchema) {} + +export const GetSkKeyListSchema = z.object({ + userId: z.string().describe('用户ID'), + pageNo: z.number().default(1), + pageSize: z.number().default(10), +}) +export class GetSkKeyListDto extends createZodDto(GetSkKeyListSchema) {} + +export const AddRefAccountSchema = z.object({ + key: z.string().describe('key'), + accountId: z.string().describe('账号ID'), +}) +export class AddRefAccountDto extends createZodDto(AddRefAccountSchema) {} + +export const GetRefAccountListSchema = z.object({ + key: z.string().describe('key'), + pageNo: z.number().default(1), + pageSize: z.number().default(10), +}) +export class GetRefAccountListDto extends createZodDto(GetRefAccountListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.controller.ts new file mode 100644 index 000000000..47078f979 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.controller.ts @@ -0,0 +1,91 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { AppException } from '@yikart/common' +import { AccountService } from '../account/account.service' +import { + AddRefAccountDto, + CreateSkKeyDto, + GetRefAccountListDto, + GetSkKeyListDto, + SkKeyKeyDto, + UpSkKeyInfoDto, +} from './dto/skKey.dto' +import { SkKeyService } from './skKey.service' + +@Controller() +export class SkKeyController { + constructor( + private readonly skKeyService: SkKeyService, + private readonly accountService: AccountService, + ) {} + + // @NatsMessagePattern('channel.skKey.create') + @Post('channel/skKey/create') + async create(@Body() data: CreateSkKeyDto) { + return await this.skKeyService.create(data) + } + + // @NatsMessagePattern('channel.skKey.del') + @Post('channel/skKey/del') + async del(@Body() data: SkKeyKeyDto) { + return this.skKeyService.del(data.key) + } + + // @NatsMessagePattern('channel.skKey.upInfo') + @Post('channel/skKey/upInfo') + async upInfo(@Body() data: UpSkKeyInfoDto) { + return this.skKeyService.upInfo(data.key, data.desc) + } + + // @NatsMessagePattern('channel.skKey.getInfo') + @Post('channel/skKey/getInfo') + async getInfo(@Body() data: SkKeyKeyDto) { + const skKey = this.skKeyService.getInfo(data.key) + return skKey + } + + // @NatsMessagePattern('channel.skKey.list') + @Post('channel/skKey/list') + async getList(@Body() data: GetSkKeyListDto) { + let { list, total } = await this.skKeyService.getList(data.userId, { + pageNo: data.pageNo, + pageSize: data.pageSize, + }) + + list = await Promise.all( + list.map(async (item: any) => { + const extendedItem = item.toObject() + extendedItem.accountNum = await this.skKeyService.getRefAccountCount( + item.key, + ) + extendedItem.active = await this.skKeyService.checkActive(item.key) + return extendedItem // 确保返回新对象 + }), + ) + + return { list, total } + } + + // @NatsMessagePattern('channel.skKey.addRefAccount') + @Post('channel/skKey/addRefAccount') + async addRefAccount(@Body() data: AddRefAccountDto) { + const account = await this.accountService.getAccountInfo(data.accountId) + if (!account) + throw new AppException(1, '账户不存在') + return this.skKeyService.addRefAccount(data.key, account) + } + + // @NatsMessagePattern('channel.skKey.delRefAccount') + @Post('channel/skKey/delRefAccount') + async delRefAccount(@Body() data: AddRefAccountDto) { + return this.skKeyService.delRefAccount(data.key, data.accountId) + } + + // @NatsMessagePattern('channel.skKey.getRefAccountList') + @Post('channel/skKey/getRefAccountList') + async getRefAccountList(@Body() data: GetRefAccountListDto) { + return this.skKeyService.getRefAccountList(data.key, { + pageNo: data.pageNo, + pageSize: data.pageSize, + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.module.ts new file mode 100644 index 000000000..162dc787c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { SkKey, SkKeySchema } from '../../libs/database/schema/skKey.schema' +import { + SkKeyRefAccount, + SkKeyRefAccountSchema, +} from '../../libs/database/schema/skKeyRefAccount.schema' +import { PublishModule } from '../publish/publish.module' +import { SkKeyController } from './skKey.controller' +import { SkKeyService } from './skKey.service' + +@Global() +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SkKey.name, schema: SkKeySchema }, + { name: SkKeyRefAccount.name, schema: SkKeyRefAccountSchema }, + ]), + PublishModule, + ], + providers: [SkKeyService], + controllers: [SkKeyController], + exports: [SkKeyService], +}) +export class SkKeyModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.service.ts new file mode 100644 index 000000000..b88887a0c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/skKey/skKey.service.ts @@ -0,0 +1,153 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2025-07-23 22:22:35 + * @LastEditors: nevin + * @Description: skKey + */ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { RedisService } from '@yikart/redis' +import { Model } from 'mongoose' +import { TableDto } from '../../common/global/dto/table.dto' +import { strUtil } from '../../common/utils/str.util' +import { Account } from '../../libs/database/schema/account.schema' +import { SkKey } from '../../libs/database/schema/skKey.schema' +import { SkKeyRefAccount } from '../../libs/database/schema/skKeyRefAccount.schema' + +@Injectable() +export class SkKeyService { + constructor( + @InjectModel(SkKey.name) + private readonly skKeyModel: Model, + @InjectModel(SkKeyRefAccount.name) + private readonly skKeyRefAccountModel: Model, + private readonly redisService: RedisService, + ) {} + + // 创建 + async create(newData: { userId: string, desc?: string }) { + const key = strUtil.generateComplexKey() + const res = await this.skKeyModel.create({ + key, + ...newData, + }) + return res.toObject() + } + + // 删除 + async del(key: string): Promise { + const res = await this.skKeyModel.deleteOne({ key }) + this.skKeyRefAccountModel.deleteMany({ key }) + return res.deletedCount > 0 + } + + // 更新 + async upInfo(key: string, desc: string) { + const res = await this.skKeyModel.updateOne({ key }, { $set: { desc } }) + return res.modifiedCount > 0 + } + + async getInfo(key: string) { + const data = await this.redisService.getJson(`skKey:${key}`) + if (data) { + return data + } + + const res = await this.skKeyModel.findOne({ key }) + await this.redisService.setJson(`skKey:${key}`, res) + return res + } + + // 检查是否活跃 + async checkActive(key: string) { + const data = await this.redisService.ttl(`skKey:${key}`) + return data > 0 + } + + /** + * key列表 + * @param userId + * @param page + * @returns + */ + async getList( + userId: string, + page: TableDto, + ): Promise<{ + total: number + list: SkKey[] + }> { + const list = await this.skKeyModel + .find({ + userId, + }) + .skip(((page.pageNo || 1) - 1) * page.pageSize) + .limit(page.pageSize) + .exec() + + return { + total: await this.skKeyModel.countDocuments({ + userId, + }), + list, + } + } + + // 创建账号关联 + async addRefAccount(key: string, account: Account) { + const res = await this.skKeyRefAccountModel.create({ + key, + accountId: account.id, + accountType: account.type, + }) + return res.toObject() + } + + // 删除账号关联 + async delRefAccount(key: string, accountId: string): Promise { + const res = await this.skKeyRefAccountModel.deleteOne({ + key, + accountId, + }) + return res.deletedCount > 0 + } + + // 获取key关联的账号的总数 + async getRefAccountCount(key: string): Promise { + const res = await this.skKeyRefAccountModel.countDocuments({ + key, + }) + return res + } + + // 获取关联列表 + async getRefAccountList( + key: string, + page: TableDto, + ): Promise<{ list: Account[], total: number }> { + const res = await this.skKeyRefAccountModel + .find({ + key, + }) + .skip(page.pageSize * (page.pageNo - 1)) + .limit(page.pageSize) + .populate('accountId') + const total = await this.skKeyRefAccountModel.countDocuments({ + key, + }) + return { + list: res.map(item => item.accountId as unknown as Account), + total, + } + } + + // 获取关联列表 + async getRefAccountAll(key: string) { + const res = await this.skKeyRefAccountModel.find({ + key, + }) + + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.controller.ts new file mode 100644 index 000000000..29d7faf9b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.controller.ts @@ -0,0 +1,26 @@ +import { Controller } from '@nestjs/common' +import { Resource } from '@rekog/mcp-nest' +import { TestService } from './test.service' + +@Controller() +export class TestController { + constructor(private readonly testService: TestService) {} + + @Resource({ + uri: 'mcp://hello-world/{userName}', + name: 'HelloWorld', + description: 'A simple greeting resource', + mimeType: 'text/plain', + }) + async getCurrentSchema({ uri, userName }) { + return { + contents: [ + { + uri, + text: `User is ${userName}`, + mimeType: 'text/plain', + }, + ], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.module.ts new file mode 100644 index 000000000..0a3a577d2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { TestController } from './test.controller' +import { TestService } from './test.service' + +@Module({ + imports: [], + controllers: [TestController], + providers: [TestService], +}) +export class TestModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.service.ts new file mode 100644 index 000000000..4856e2ca4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/core/test/test.service.ts @@ -0,0 +1,34 @@ +import { Injectable } from '@nestjs/common' +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: 测试 + */ +@Injectable() +export class TestService { + constructor( + ) { + // const data = { + // userId: '68abd359eb8332b30dba9c30', + // type: 'bilibili', + // uid: '675086d1edd54f4698741de23777929c', + // account: '675086d1edd54f4698741de23777929c', + // avatar: 'account/avatar/202509/7be9e31e-a972-4b58-8dc3-59efb9609a36.jpg', + // nickname: '刚断奶的大爷', + // } + + // const data = { + // userId: '68abd359eb8332b30dba9c30', + // type: 'bilibili', + // uid: '675086d1edd54f4698741de23777929c', + // account: '675086d1edd54f4698741de23777929c', + // avatar: 'account/avatar/202509/68fdd3c7-ca5b-4c05-a76d-30f3e102b629.jpg', + // nickname: '刚断奶的大爷', + // loginTime: '2025-09-22T14:36:43.673Z', + // } + + // this.AccountInternalApi.createAccount(data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.constants.ts new file mode 100644 index 000000000..4954e2ce8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.constants.ts @@ -0,0 +1 @@ +export const ALI_GREEN_CLIENT = 'ALI_GREEN_CLIENT' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.module.ts new file mode 100644 index 000000000..10a9db566 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.module.ts @@ -0,0 +1,43 @@ +import Credential, { Config } from '@alicloud/credentials' +/* + * @Author: white + * @Date: 2025-09-18 14:39:05 + * @LastEditTime: 2025-09-18 16:39:00 + * @LastEditors: white + * @Description: + */ +import Green20220302 from '@alicloud/green20220302' +import * as $OpenApi from '@alicloud/openapi-client' +import { Global, Module } from '@nestjs/common' +import { config } from '../../config' +import { ALI_GREEN_CLIENT } from '../ali-green/ali-green-api.constants' +import { AliGreenApiService } from '../ali-green/ali-green-api.service' + +@Global() +@Module({ + providers: [ + AliGreenApiService, + { + provide: ALI_GREEN_CLIENT, + useFactory: () => { + const { accessKeyId, accessKeySecret, endpoint } = config.aliGreen + const credentialsConfig = new Config({ + // 凭证类型。 + type: 'access_key', + // 设置accessKeyId值,此处已从环境变量中获取accessKeyId为例。 + accessKeyId, + // 设置accessKeySecret值,此处已从环境变量中获取accessKeySecret为例。 + accessKeySecret, + }) + const credential = new Credential(credentialsConfig) + const ali_config = new $OpenApi.Config({ + credential, + }) + ali_config.endpoint = endpoint + return new Green20220302(ali_config) + }, + }, + ], + exports: [ALI_GREEN_CLIENT, AliGreenApiService], +}) +export class AliGreenApiModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.service.ts new file mode 100644 index 000000000..0f022020f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-green/ali-green-api.service.ts @@ -0,0 +1,74 @@ +import { randomBytes } from 'node:crypto' +import Green20220302, * as $Green20220302 from '@alicloud/green20220302' +import * as $Util from '@alicloud/tea-util' +/* + * @Author: white + * @Date: 2025-09-18 01:15:15 + * @LastEditTime: 2025-09-18 01:42:41 + * @LastEditors: white + * @Description: + */ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ALI_GREEN_CLIENT } from './ali-green-api.constants' + +@Injectable() +export class AliGreenApiService { + private readonly logger = new Logger(AliGreenApiService.name) + constructor( + @Inject(ALI_GREEN_CLIENT) private readonly client: Green20220302, + ) {} + + async textGreen( + content: string, + ) { + const textModerationRequest = new $Green20220302.TextModerationPlusRequest({ service: 'comment_detection', serviceParameters: JSON.stringify({ content }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .textModerationWithOptions(textModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, content) + return error.message + }) + } + + async imgGreen( + imageUrl: string, + ) { + const dataId = randomBytes(4).toString('hex').slice(0, 8) + const serviceParameters = JSON.stringify({ dataId, imageUrl }) + const imageModerationRequest = new $Green20220302.ImageModerationRequest({ service: 'baselineCheck', serviceParameters }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .imageModerationWithOptions(imageModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, dataId, imageUrl) + return error.message + }) + } + + async videoGreen( + url: string, + ) { + const videoModerationRequest = new $Green20220302.VideoModerationRequest({ service: 'videoDetection', serviceParameters: JSON.stringify({ url }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .videoModerationWithOptions(videoModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, url) + return error.message + }) + } + + async getVideoResult( + taskId: string, + ) { + const videoModerationRequest = new $Green20220302.VideoModerationResultRequest({ service: 'videoDetection', serviceParameters: JSON.stringify({ taskId }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .videoModerationResultWithOptions(videoModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, taskId) + return error.message + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.constants.ts new file mode 100644 index 000000000..c93e0983f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.constants.ts @@ -0,0 +1,2 @@ +export const ALI_OSS_MODULE_OPTIONS = 'ALI_OSS_MODULE_OPTIONS' +export const ALI_OSS_CLIENT = 'ALI_OSS_CLIENT' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.interfaces.ts new file mode 100644 index 000000000..4ffff09f8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.interfaces.ts @@ -0,0 +1,19 @@ +export interface AliOSSModuleOptions { + accessKeyId: string + accessKeySecret: string + bucket: string + region: string + endpoint?: string + internal?: boolean + secure?: boolean + timeout?: string | number + cname?: boolean + isRequestPay?: boolean +} + +export interface AliOSSModuleAsyncOptions { + useFactory: ( + ...args: any[] + ) => Promise | AliOSSModuleOptions + inject?: any[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.module.ts new file mode 100644 index 000000000..74477a968 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.module.ts @@ -0,0 +1,62 @@ +import { DynamicModule, Module, Provider } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import OSS from 'ali-oss' +import { ALI_OSS_CLIENT, ALI_OSS_MODULE_OPTIONS } from './ali-oss.constants' +import { + AliOSSModuleAsyncOptions, + AliOSSModuleOptions, +} from './ali-oss.interfaces' +import { AliOSSService } from './ali-oss.service' + +@Module({}) +export class AliOSSModule { + static forRoot(options: AliOSSModuleOptions): DynamicModule { + return { + module: AliOSSModule, + providers: [ + { + provide: ALI_OSS_MODULE_OPTIONS, + useValue: options, + }, + { + provide: ALI_OSS_CLIENT, + useFactory: (options: AliOSSModuleOptions): OSS => { + return new OSS(options) + }, + inject: [ALI_OSS_MODULE_OPTIONS], + }, + AliOSSService, + ], + exports: [AliOSSService], + } + } + + static forRootAsync(options: AliOSSModuleAsyncOptions): DynamicModule { + return { + module: AliOSSModule, + imports: [ConfigModule], + providers: [ + this.createAsyncOptionsProvider(options), + { + provide: ALI_OSS_CLIENT, + useFactory: (options: AliOSSModuleOptions): OSS => { + return new OSS(options) + }, + inject: [ALI_OSS_MODULE_OPTIONS], + }, + AliOSSService, + ], + exports: [AliOSSService], + } + } + + private static createAsyncOptionsProvider( + options: AliOSSModuleAsyncOptions, + ): Provider { + return { + provide: ALI_OSS_MODULE_OPTIONS, + useFactory: options.useFactory, + inject: options.inject || [], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.service.ts new file mode 100644 index 000000000..34e0aec8a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/ali-oss/ali-oss.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable, OnModuleInit } from '@nestjs/common' +import * as OSS from 'ali-oss' +import { ALI_OSS_CLIENT } from './ali-oss.constants' + +@Injectable() +export class AliOSSService implements OnModuleInit { + constructor(@Inject(ALI_OSS_CLIENT) private readonly ossClient: OSS) {} + + onModuleInit() {} + + get client(): OSS { + return this.ossClient + } + + // 这里可以添加常用的OSS方法封装,例如: + putObject( + key: string, + file: Buffer | string, + options?: OSS.PutObjectOptions, + ) { + return this.ossClient.put(key, file, options) + } + + getObject(key: string, options?: OSS.GetObjectOptions) { + return this.ossClient.get(key, options) + } + + deleteObject(key: string) { + return this.ossClient.delete(key) + } + + listObjects(query: OSS.ListObjectsQuery) { + return this.ossClient.list(query) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/common.ts new file mode 100644 index 000000000..95e8a0bb4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/common.ts @@ -0,0 +1,12 @@ +export interface S3ModuleOptions { + region: string + accessKeyId: string + secretAccessKey: string + bucketName: string +} + +export interface S3ModuleAsyncOptions { + useFactory: (...args: any[]) => Promise | S3ModuleOptions + inject?: any[] + imports?: any[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.config.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.config.ts new file mode 100644 index 000000000..880e7f572 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.config.ts @@ -0,0 +1,12 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const s3ConfigSchema = z.object({ + region: z.string().default(''), + accessKeyId: z.string().default(''), + secretAccessKey: z.string().default(''), + bucketName: z.string().default(''), + hostUrl: z.string().default(''), +}) + +export class S3Config extends createZodDto(s3ConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.factory.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.factory.ts new file mode 100644 index 000000000..3e6d0c45a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.factory.ts @@ -0,0 +1,14 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { S3ModuleOptions } from './common' + +export class S3Factory { + static createS3Client(options: S3ModuleOptions): S3Client { + return new S3Client({ + region: options.region, + credentials: { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + }, + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.module.ts new file mode 100644 index 000000000..175cdf51e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.module.ts @@ -0,0 +1,63 @@ +import { DynamicModule, Module, Provider } from '@nestjs/common' +import { S3ModuleAsyncOptions, S3ModuleOptions } from './common' +import { S3Config } from './s3.config' +import { S3Factory } from './s3.factory' +import { S3Service } from './s3.service' + +@Module({}) +export class S3Module { + // 同步配置 + static forRoot(config: S3Config): DynamicModule { + const providers: Provider[] = [ + { provide: S3Config, useValue: config }, + { + provide: 'S3_CONFIG', + useValue: config, + }, + { + provide: S3Service, + useFactory: (s3Config: S3Config) => { + const service = new S3Service() + const client = S3Factory.createS3Client(s3Config) + service.registerClient(s3Config.bucketName, client, true) + return service + }, + inject: [S3Config], + }, + ] + + return { + module: S3Module, + providers, + exports: [S3Service], + } + } + + // 异步配置(如从 ConfigService 读取) + static forRootAsync(options: S3ModuleAsyncOptions): DynamicModule { + const providers: Provider[] = [ + { + provide: 'S3_CONFIG', + useFactory: options.useFactory, + inject: options.inject || [], + }, + { + provide: S3Service, + useFactory: (config: S3ModuleOptions) => { + const service = new S3Service() + const client = S3Factory.createS3Client(config) + service.registerClient(config.bucketName, client, true) + return service + }, + inject: ['S3_CONFIG'], + }, + ] + + return { + module: S3Module, + imports: options.imports || [], + providers, + exports: [S3Service], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.service.ts new file mode 100644 index 000000000..cdd787a60 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/aws-s3/s3.service.ts @@ -0,0 +1,147 @@ +import { + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, + UploadPartCommand, + UploadPartCommandOutput, +} from '@aws-sdk/client-s3' +import { getSignedUrl } from '@aws-sdk/s3-request-presigner' +import { Injectable, Logger } from '@nestjs/common' +import { AppException } from '@yikart/common' + +@Injectable() +export class S3Service { + private readonly clients: Map = new Map() + private defaultBucket: string + private readonly logger = new Logger(S3Service.name) + // 注册 S3 客户端实例 + registerClient(name: string, client: S3Client, isDefault = false) { + this.clients.set(name, client) + if (isDefault) + this.defaultBucket = name + } + + // 获取客户端实例 + private getClient(bucketName?: string): S3Client { + const targetBucket = bucketName || this.defaultBucket + const client = this.clients.get(targetBucket) + if (!client) + throw new Error(`S3 client for bucket ${targetBucket} not found`) + return client + } + + // 上传文件(可指定 Bucket) + async uploadFile( + key: string, + file: Buffer, + contentType = 'application/octet-stream', + bucketName?: string, + ) { + const client = this.getClient(bucketName) + + const command = new PutObjectCommand({ + Bucket: bucketName || this.defaultBucket, + Key: key, + Body: file, + ContentType: contentType, + }) + try { + await client.send(command) + return { key } + } + catch (error) { + this.logger.debug(error) + throw new AppException(1, `Failed to upload file to S3: ${error.message}`) + } + } + + // 生成预签名 URL(可指定 Bucket) + getFileUrl(key: string, bucketName?: string, expiresIn = 3600) { + const client = this.getClient(bucketName) + const command = new GetObjectCommand({ + Bucket: bucketName || this.defaultBucket, + Key: key, + }) + return getSignedUrl(client, command, { expiresIn }) + } + + /** + * 开始分片上传 + * @param {string} bucketName - 存储桶名称 + * @param {string} key - 文件键 + * @returns {Promise} - 返回上传ID + */ + async initiateMultipartUpload( + key: string, + bucketName?: string, + ): Promise { + bucketName = bucketName || this.defaultBucket + const client = this.getClient(bucketName) + + const command = new CreateMultipartUploadCommand({ + Bucket: bucketName, + Key: key, + }) + + const response = await client.send(command) + return response.UploadId || '' + } + + /** + * 上传单个分片 + * @param {string} key - 文件键 + * @param {string} uploadId - 上传ID + * @param {number} partNumber - 分片编号 + * @param {Buffer} partData - 分片数据 + * @param {string} bucketName - 存储桶名称 + * @returns {Promise} - 返回ETag + */ + async uploadPart( + key: string, + uploadId: string, + partNumber: number, + partData: Buffer, + bucketName?: string, + ): Promise { + bucketName = bucketName || this.defaultBucket + const client = this.getClient(bucketName) + const command = new UploadPartCommand({ + Bucket: bucketName, + Key: key, + UploadId: uploadId, + PartNumber: partNumber, + Body: partData, + }) + + const response = await client.send(command) + return response + } + + /** + * 完成分片上传 + * @param {string} bucketName - 存储桶名称 + * @param {string} key - 文件键 + * @param {string} uploadId - 上传ID + * @param {Array<{ PartNumber: number; ETag: string }>} parts - 分片列表 + */ + async completeMultipartUpload( + key: string, + uploadId: string, + parts: { PartNumber: number, ETag: string }[], + bucketName?: string, + ): Promise { + bucketName = bucketName || this.defaultBucket + const client = this.getClient(bucketName) + + const command = new CompleteMultipartUploadCommand({ + Bucket: bucketName, + Key: key, + UploadId: uploadId, + MultipartUpload: { Parts: parts }, + }) + + await client.send(command) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.module.ts new file mode 100644 index 000000000..23332d9ee --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:27 + * @LastEditTime: 2025-02-25 09:47:37 + * @LastEditors: nevin + * @Description: bilibili Bilibili + */ +import { Module } from '@nestjs/common' +import { BilibiliApiService } from './bilibiliApi.service' + +@Module({ + imports: [], + providers: [BilibiliApiService], + exports: [BilibiliApiService], +}) +export class BilibiliApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.service.ts new file mode 100644 index 000000000..0ae5e46a7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/bilibiliApi.service.ts @@ -0,0 +1,461 @@ +import { createHash, createHmac } from 'node:crypto' +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-04-14 16:50:44 + * @LastEditors: nevin + * @Description: Bilibili bilibili + */ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { v4 as uuidv4 } from 'uuid' +import { getRandomString } from '../../common' +import { config } from '../../config' +import { + AccessToken, + AccessTokenResponse, + AddArchiveData, + ArchiveAddByUtokenResponse, + ArchiveListData, + ArchiveListResponse, + ArchiveStatus, + ArchiveTypeItem, + ArchiveTypeListResponse, + ArcIncStatData, + ArcIncStatResponse, + ArcStatData, + ArcStatResponse, + BilibiliUser, + BilibiliUserInfoResponse, + DeleteVideoResponse, + EtagResponse, + GrantScopesResponse, + UploadCoverImgResponse, + UserStatData, + UserStatResponse, + VideoInitialResponse, + VideoUTypes, +} from './common' + +@Injectable() +export class BilibiliApiService { + private readonly logger = new Logger(BilibiliApiService.name) + private readonly appId: string + private readonly appSecret: string + constructor() { + const cfg = config.bilibili + this.appId = cfg.id + this.appSecret = cfg.secret + } + + getAppInfo() { + return { + appId: this.appId, + appSecret: this.appSecret, + } + } + + private async request(url: string, config: AxiosRequestConfig = {}): Promise { + this.logger.debug(`Bilibili API Request: ${url} with config: ${JSON.stringify(config)}`) + try { + const response: AxiosResponse = await axios(url, config) + if (response.data['code'] !== 0) { + this.logger.error(`Bilibili API returned an error: ${url}, response: ${JSON.stringify(response.data)}`) + throw new Error(response.data['message'] || 'Bilibili API returned an error') + } + return response.data + } + catch (error) { + if (error.response) { + this.logger.error(`Bilibili API request failed: ${url}, status: ${error.response.status}, data: ${JSON.stringify(error.response.data)}`) + throw new Error(`Bilibili API request failed: ${error.response.data.error.message}`) + } + this.logger.error(`Bilibili API request failed: ${url}`, error) + throw new Error(`Bilibili API request failed: ${error.message}`) + } + } + + /** + * 获取登陆授权页 + * @param redirectURL 回调地址 + * @param type 回调地址 + * @returns + */ + getAuthPage(redirectURL: string, type: 'h5' | 'pc') { + const state = getRandomString(8) + const url + = type === 'h5' + ? `https://account.bilibili.com/h5/account-h5/auth/oauth?navhide=1&callback=skip&gourl=${redirectURL}&client_id=${this.appId}&state=${state}` + : `https://account.bilibili.com/pc/account-pc/auth/oauth?client_id=${this.appId}&gourl=${redirectURL}&state=${state}` + + return { + url, + state, + } + } + + /** + * 设置用户的授权Token + * @param data + * @returns + */ + async getAccessToken(code: string) { + const config: AxiosRequestConfig = { + method: 'POST', + params: { + client_id: this.appId, + client_secret: this.appSecret, + grant_type: 'authorization_code', + code, + }, + } + + const resp = await this.request('https://api.bilibili.com/x/account-oauth2/v1/token', config) + return resp.data + } + + /** + * 刷新授权Token + * @param refreshToken + * @returns + */ + async refreshAccessToken(refreshToken: string): Promise { + const url = `https://api.bilibili.com/x/account-oauth2/v1/refresh_token` + const config: AxiosRequestConfig = { + method: 'POST', + params: { + client_id: this.appId, + client_secret: this.appSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, + } + const resp = await this.request(url, config) + return resp.data + } + + /** + * 生成请求头 + * @param data + */ + generateHeader( + data: { + accessToken: string + body?: { [key: string]: any } + }, + isForm = false, + ) { + const { accessToken, body } = data + + const md5Str = body ? JSON.stringify(body) : '' + const xBiliContentMd5 = createHash('md5').update(md5Str).digest('hex') + + const header = { + 'Accept': 'application/json', + 'Content-Type': isForm ? 'multipart/form-data' : 'application/json', // 或者 multipart/form-data + 'x-bili-content-md5': xBiliContentMd5, + 'x-bili-timestamp': Math.floor(Date.now() / 1000), + 'x-bili-signature-method': 'HMAC-SHA256', + 'x-bili-signature-nonce': uuidv4(), + 'x-bili-accesskeyid': this.appId, + 'x-bili-signature-version': '2.0', + 'access-token': accessToken, // 需要在请求头中添加access-token + 'Authorization': '', + } + + // 抽取带"x-bili-"前缀的自定义header,按字典排序拼接,构建完整的待签名字符串: + // 待签名字符串包含换行符\n + const headerStr = Object.keys(header) + .filter(key => key.startsWith('x-bili-')) + .sort() + .map(key => `${key}:${header[key]}`) + .join('\n') + + // 使用 createHmac 正确创建签名 + const signature = createHmac('sha256', this.appSecret) + .update(headerStr) + .digest('hex') + + // 将签名加入 header + header.Authorization = signature + + return header + } + + /** + * 获取授权用户信息 + * @param accessToken + * @returns + */ + async getAccountInfo(accessToken: string): Promise { + const url = `https://member.bilibili.com/arcopen/fn/user/account/info` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + const resp = await this.request(url, config) + return resp.data + } + + /** + * 查询用户已授权权限列表 + * @param accessToken + * @returns + */ + async getAccountScopes(accessToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/user/account/scopes` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + + const resp = await this.request(url, config) + return resp.data + } + + /** + * 视频初始化 + * @param accessToken + * @param fileName + * @param utype // 1-单个小文件(不超过100M)。默认值为0 + * @returns + */ + async videoInit( + accessToken: string, + fileName: string, + utype: VideoUTypes = 0, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/video/init` + const data = { + name: fileName, // test.mp4 + utype: `${utype}`, + } + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken, body: data }), + data, + } + const resp = await this.request(url, config) + return resp.data.upload_token + } + + /** + * 视频分片上传 + * @param accessToken + * @param file + * @param uploadToken + * @param partNumber + * @returns + */ + async uploadVideoPart( + accessToken: string, + file: Buffer, + uploadToken: string, + partNumber: number, + ) { + const url = `https://openupos.bilivideo.com/video/v2/part/upload` + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken }), + params: { + upload_token: uploadToken, + part_number: partNumber, + }, + data: file, + } + const result = await this.request(url, config) + return result.data.etag + } + + /** + * 文件分片合片 + * @param accessToken + * @param uploadToken + * @returns + */ + async videoComplete(accessToken: string, uploadToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/archive/video/complete` + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken }), + params: { + upload_token: uploadToken, + }, + } + return await this.request<{ code: number, message: string }>(url, config) + } + + /** + * 封面上传 + * @param accessToken + * @param fileBase64 + * @returns + */ + async coverUpload(accessToken: string, fileBase64: string): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/cover/upload` + const buffer = Buffer.from(fileBase64, 'base64') + const blob = new Blob([buffer], { type: 'image/jpeg' }) + + const formData = new FormData() + formData.append('file', blob) + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken }, true), + data: formData, + } + const resp = await this.request(url, config) + return resp.data.url + } + + /** + * 小视频上传 100M以下 + * @param accessToken + * @param file + * @param uploadToken + * @returns + */ + async uploadLitVideo(accessToken: string, file: Buffer, uploadToken: string) { + const result = await axios.post<{ + code: number // 0; + message: string // '0'; + }>( + `https://https://openupos.bilivideo.com/video/v2/upload?upload_token=${uploadToken}`, + file, + { + headers: this.generateHeader({ + accessToken, + }), + }, + ) + + return result.data + } + + /** + * 视频稿件提交 + * @param accessToken + * @param uploadToken + * @param inData + * @returns + */ + async archiveAddByUtoken( + accessToken: string, + uploadToken: string, + inData: AddArchiveData, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/add-by-utoken` + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken, body: inData }), + params: { upload_token: uploadToken }, + data: inData, + } + const result = await this.request(url, config) + if (result.code !== 0) { + throw new Error(result.message) + } + return result.data.resource_id + } + + /** + * 分区查询 + * @param accessToken + * @returns + */ + async archiveTypeList(accessToken: string): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/type/list` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + const result = await this.request(url, config) + return result.data + } + + /** + * 获取稿件列表 + * @param accessToken + * @param params + * 可选值:all(全部),is_pubing(发布中),pubed(已发布),not_pubed(未发布)。不填查询全部 + * @returns + */ + async getArchiveList( + accessToken: string, + params: { + ps: number + pn: number + status?: ArchiveStatus + }, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/viewlist` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + params, + } + const result = await this.request(url, config) + return result.data + } + + /** + * 获取用户数据 + * @param accessToken + * @returns + */ + async getUserStat(accessToken: string): Promise { + const url = `https://member.bilibili.com/arcopen/fn/data/user/stat` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + const result = await this.request(url, config) + return result.data + } + + /** + * 获取稿件数据 + * @param accessToken + * @param resourceId + * @returns + */ + async getArcStat( + accessToken: string, + resourceId: string, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/data/arc/stat?resource_id=${resourceId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + const result = await this.request(url, config) + return result.data + } + + /** + * 获取稿件增量数据数据 + * @param accessToken + * @returns + */ + async getArcIncStat(accessToken: string): Promise { + const url = `https://member.bilibili.com/arcopen/fn/data/arc/inc-stats` + const config: AxiosRequestConfig = { + method: 'GET', + headers: this.generateHeader({ accessToken }), + } + const result = await this.request(url, config) + return result.data + } + + async deleteArchive(accessToken: string, videoId: string): Promise { + const url = 'https://member.bilibili.com/arcopen/fn/archive/delete' + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.generateHeader({ accessToken }), + data: { + resource_id: videoId, + }, + } + const result = await this.request(url, config) + return result + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/common.ts new file mode 100644 index 000000000..24a6189f8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/bilibili/common.ts @@ -0,0 +1,152 @@ +export interface ArchiveTypeChild { + description: string + id: number + name: string + parent: number +} +export interface ArchiveTypeItem { + children: ArchiveTypeChild[] + description: string + id: number + name: string + parent: number +} +export interface ArchiveListItem { + addit_info: { + reject_reason: string + state: number + state_desc: string + } + copyright: number + cover: string + ctime: number + desc: string + no_reprint: number + ptime: number + resource_id: string + tag: string + tid: number + title: string + video_info: { + cid: number + duration: number + filename: string + iframe_url: string + share_url: string + } +} +export interface ArchiveListPage { + pn: number + ps: number + total: number +} +export interface ArchiveListData { + list: ArchiveListItem[] + page: ArchiveListPage +} +export interface UserStatData { + arc_passed_total: number + follower: number + following: number +} +export interface ArcStatData { + coin: number + danmaku: number + favorite: number + like: number + ptime: number + reply: number + share: number + title: string + view: number +} +export interface ArcIncStatData { + inc_click: number + inc_coin: number + inc_dm: number + inc_elec: number + inc_fav: number + inc_like: number + inc_reply: number + inc_share: number +} +export interface ArchiveAddByUtokenData { + resource_id: string +} +// ...existing code... + +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface CommonResponse { + code: number // 0; + message: string // '0'; + ttl: number // 1; + data: T +} + +export interface AccessToken { + access_token: string // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number // 1630220614; + refresh_token: string // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[] // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; +} + +export interface AddArchiveData { + title: string // 标题 + cover?: string // 封面url + tid: number // 分区ID,由获取分区信息接口得到 + no_reprint?: 0 | 1 // 是否允许转载 0-允许,1-不允许。默认0 + desc?: string // 描述 + tag: string // 标签, 多个标签用英文逗号分隔,总长度小于200 + copyright: 1 | 2 // 1-原创,2-转载(转载时source必填) + source?: string // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number // 参加的话题ID,默认情况下不填写,需要填写和运营联系 +} + +export interface BilibiliUser { + face: string // 'https://i0.hdslb.com/bfs/face/43d971688595deed3b3c27b61225c0fe67d3076b.jpg'; + name: string // 'user_80800215578'; + openid: string // 'fc9899b46ff443cea38190d355d49f3a'; +} + +// status?: 'all' | 'is_pubing' | 'pubed' | 'not_pubed'; +export enum ArchiveStatus { + all = 'all', + is_pubing = 'is_pubing', + pubed = 'pubed', + not_pubed = 'not_pubed', +} + +export interface GrantScopes { + openid: string + scopes: string[] +} + +export interface videoInitialData { + upload_token: string +} + +export interface etagData { + etag: string +} + +export interface DeleteVideoData {} + +export interface DeleteVideoResponse extends CommonResponse { } + +export interface ArchiveTypeListResponse extends CommonResponse { } +export interface ArchiveListResponse extends CommonResponse { } +export interface UserStatResponse extends CommonResponse { } +export interface ArcStatResponse extends CommonResponse { } +export interface ArcIncStatResponse extends CommonResponse { } +export interface ArchiveAddByUtokenResponse extends CommonResponse { } +export interface AccessTokenResponse extends CommonResponse { } +export interface BilibiliUserInfoResponse extends CommonResponse { } +export interface AddArchiveResponse extends CommonResponse { } +export interface GrantScopesResponse extends CommonResponse { } +export interface VideoInitialResponse extends CommonResponse { } +export interface EtagResponse extends CommonResponse { } +export interface UploadCoverImgResponse extends CommonResponse<{ url: string }> { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/db-mongo.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/db-mongo.module.ts new file mode 100644 index 000000000..c53beabce --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/db-mongo.module.ts @@ -0,0 +1,30 @@ +/* + * @Author: nevin + * @Date: 2022-09-23 18:00:51 + * @LastEditTime: 2025-01-15 14:20:46 + * @LastEditors: nevin + * @Description: + */ +import { Global, Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import { config } from '../../config' +import { Id, IdSchema } from './id.schema' +import { IdService } from './id.service' + +@Global() +@Module({ + imports: [ + MongooseModule.forRoot(config.mongodb.uri, { + dbName: config.mongodb.dbName, + // autoIndex: true, + // autoCreate: true, + }), + MongooseModule.forFeature([ + // 挂载实体 + { name: Id.name, schema: IdSchema }, + ]), + ], + providers: [IdService], + exports: [IdService], +}) +export class DbMongoModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.schema.ts new file mode 100644 index 000000000..52a446b54 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.schema.ts @@ -0,0 +1,21 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 自增主键ID + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' + +@Schema({ collection: 't_ids', versionKey: false }) +export class Id { + @Prop({ required: true }) + id_value: number + + @Prop({ required: true }) + id_name: string + + @Prop() + update_time: number +} +export const IdSchema = SchemaFactory.createForClass(Id) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.service.ts new file mode 100644 index 000000000..f193efd8d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/id.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +/* + * @Author: nevin + * @Date: 2021-12-24 13:49:52 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:55 + * @Description: 自增ID + */ +import { Model } from 'mongoose' +import { Id } from './id.schema' + +@Injectable() +export class IdService { + constructor(@InjectModel(Id.name) private readonly idModel: Model) {} + + /** + * @description: 创建id + * @param {string} id_name id名称 + * @param {number} id_value id + * @return: Promise + */ + public async createId( + id_name: string, + id_value: number, + id_type: T, + ): Promise { + const fadArgs = { + query: { + id_name, + }, + update: { + $inc: { id_value: 1 }, + $set: { update_time: new Date() }, + }, + options: { new: true }, + } + let newId = await this.idModel + .findOneAndUpdate(fadArgs.query, fadArgs.update, fadArgs.options) + .exec() + if (newId) { + return newId.id_value + } + const createdUser = new this.idModel({ id_name, id_value }) + newId = await createdUser.save() + + const id + = typeof id_type === 'string' ? newId.id_value.toString() : newId.id_value + + return id + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/index.ts new file mode 100644 index 000000000..f13fdf458 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/index.ts @@ -0,0 +1,3 @@ +export * from './db-mongo.module' +export * from './id.schema' +export * from './id.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/account.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/account.schema.ts new file mode 100644 index 000000000..d7a840fea --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/account.schema.ts @@ -0,0 +1,69 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { Schema as MongooseSchema } from 'mongoose' +import { BaseTemp } from './time.tamp' + +export enum AccountStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +@Schema({ + collection: 'account', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Account extends BaseTemp { + @Prop({ type: MongooseSchema.Types.String }) + _id: string + + id: string + + @Prop({ + required: true, + type: String, + }) + userId: string + + @Prop({ + required: true, + enum: AccountType, + }) + type: AccountType + + @Prop({ + required: true, // 平台账户的唯一ID + }) + uid: string + + @Prop({ + required: false, // 部分平台的补充ID + }) + account: string + + @Prop({ + required: false, + type: Date, + }) + loginTime?: Date + + @Prop({ + required: false, + }) + avatar?: string + + @Prop({ + required: true, + }) + nickname: string + + @Prop({ + required: true, + default: AccountStatus.NORMAL, + }) + status: AccountStatus // 登录状态,用于判断是否失效 +} + +export const AccountSchema = SchemaFactory.createForClass(Account) +AccountSchema.index({ type: 1, uid: 1 }, { unique: true }) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/engagement.task.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/engagement.task.schema.ts new file mode 100644 index 000000000..1510ec831 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/engagement.task.schema.ts @@ -0,0 +1,175 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { BaseTemp } from './time.tamp' + +export enum EngagementTaskStatus { + CREATED = 'CREATED', + DISTRIBUTED = 'DISTRIBUTED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + PAUSED = 'PAUSED', + CANCELED = 'CANCELED', + FAILED = 'FAILED', + PARTIALLY_COMPLETED = 'PARTIALLY_COMPLETED', +} +export enum EngagementTaskType { + LIKE = 'LIKE', + FAVORITE = 'FAVORITE', + COMMENT = 'COMMENT', // comment on post + REPLY = 'REPLY', // reply to comment +} + +export enum EngagementTargetScope { + ALL = 'ALL', + PARTIAL = 'PARTIAL', +} + +@Schema({ + collection: 'engagementTask', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class EngagementTask extends BaseTemp { + id: string + @Prop({ + required: true, + }) + accountId: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + postId: string + + @Prop({ + required: true, + }) + platform: string + + @Prop({ + required: true, + default: '', + }) + model: string + + @Prop({ + required: false, + default: '', + }) + prompt: string + + @Prop({ + required: true, + enum: EngagementTaskType, + default: EngagementTaskType.REPLY, + }) + taskType: EngagementTaskType + + @Prop({ + required: true, + enum: EngagementTargetScope, + default: EngagementTargetScope.ALL, + }) + targetScope: EngagementTargetScope + + @Prop({ + required: false, + type: [String], + }) + targetIds: string[] + + @Prop({ + required: true, + enum: EngagementTaskStatus, + default: EngagementTaskStatus.CREATED, + }) + status: EngagementTaskStatus + + @Prop({ + required: true, + default: 0, + }) + subTaskCount: number + + @Prop({ + required: true, + default: 0, + }) + completedSubTaskCount: number + + @Prop({ + required: true, + default: 0, + }) + failedSubTaskCount: number +} + +@Schema({ + collection: 'engagementSubTask', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class EngagementSubTask extends BaseTemp { + id: string + + @Prop({ + required: true, + index: true, + type: String, + }) + taskId: string + + @Prop({ + required: true, + }) + accountId: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + postId: string + + @Prop({ + required: true, + }) + commentId: string + + @Prop({ + required: true, + default: '', + }) + commentContent: string + + @Prop({ + required: false, + default: '', + }) + replyContent: string + + @Prop({ + required: true, + }) + platform: string + + @Prop({ + required: true, + enum: EngagementTaskStatus, + default: EngagementTaskStatus.CREATED, + }) + status: EngagementTaskStatus +} +export const EngagementTaskSchema = SchemaFactory.createForClass(EngagementTask) +export const EngagementSubTaskSchema = SchemaFactory.createForClass(EngagementSubTask) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/interactionRecord.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/interactionRecord.schema.ts new file mode 100644 index 000000000..5e41bc110 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/interactionRecord.schema.ts @@ -0,0 +1,99 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 互动记录记录 interactionRecord + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { BaseTemp } from './time.tamp' + +@Schema({ + collection: 'interactionRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class InteractionRecord extends BaseTemp { + id: string + + @Prop({ + required: true, + index: true, + type: String, + }) + userId: string + + @Prop({ + required: true, + index: true, + type: String, + }) + accountId: string + + @Prop({ + required: true, + enum: AccountType, + }) + type: AccountType + + @Prop({ + required: true, + index: true, + type: String, + }) + worksId: string + + // 作品标题 + @Prop({ + type: String, + default: '', + }) + worksTitle?: string + + // 作品封面 + @Prop({ + type: String, + }) + worksCover?: string + + @Prop({ + type: String, + default: '', + }) + worksContent?: string + + @Prop({ + type: String, + }) + commentContent?: string + + // 评论时间 + @Prop({ + type: Date, + default: null, + }) + commentTime?: Date + + // 评论备注 + @Prop({ + type: String, + }) + commentRemark?: string + + // 点赞时间 + @Prop({ + type: Date, + }) + likeTime?: Date + + // 收藏时间 + @Prop({ + type: Date, + }) + collectTime?: Date +} + +export const InteractionRecordSchema = SchemaFactory.createForClass(InteractionRecord) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/oauth2Crendential.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/oauth2Crendential.schema.ts new file mode 100644 index 000000000..9aa7431f8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/oauth2Crendential.schema.ts @@ -0,0 +1,58 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { BaseTemp } from './time.tamp' + +// 账号状态 +export enum TokenStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +@Schema({ + collection: 'oauth2Crendential', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class OAuth2Crendential extends BaseTemp { + @Prop({ + required: true, + type: String, + }) + accountId: string + + @Prop({ + required: true, + enum: AccountType, + }) + platform: AccountType + + @Prop({ + required: true, + type: String, + default: '', + }) + accessToken: string + + @Prop({ + required: true, + type: String, + default: '', + }) + refreshToken: string + + @Prop({ + required: true, + type: Number, + }) + accessTokenExpiresAt: number + + @Prop({ + required: false, + type: Number, + }) + refreshTokenExpiresAt?: number +} + +export const OAuth2CrendentialSchema = SchemaFactory.createForClass(OAuth2Crendential) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/postMediaContainer.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/postMediaContainer.schema.ts new file mode 100644 index 000000000..ac7083a10 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/postMediaContainer.schema.ts @@ -0,0 +1,89 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import mongoose from 'mongoose' +import { PlatPulOption } from '../../../core/publish/common' +import { BaseTemp } from './time.tamp' + +export enum PostMediaStatus { + FAILED = -1, + CREATED = 0, + IN_PROGRESS = 1, + FINISHED = 2, +} + +export enum PostCategory { + POST = 'POST', + REELS = 'REELS', + STORY = 'STORY', +} + +export enum PostSubCategory { + PLAINTEXT = 'PLAINTEXT', + PHOTO = 'PHOTO', + VIDEO = 'VIDEO', +} + +@Schema({ + collection: 'postMediaContainer', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) + +export class PostMediaContainer extends BaseTemp { + id: string + + @Prop({ + required: true, + }) + publishId: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + platform: string + + @Prop({ + required: true, + }) + taskId: string + + @Prop({ + required: true, + enum: PostCategory, + default: PostCategory.POST, + }) + category: PostCategory + + @Prop({ + required: true, + enum: PostSubCategory, + default: PostSubCategory.PLAINTEXT, + }) + subCategory: PostSubCategory + + @Prop({ + required: true, + enum: PostMediaStatus, + default: PostMediaStatus.CREATED, + }) + status: PostMediaStatus + + @Prop({ + required: true, + }) + accountId: string + + @Prop({ + required: false, + type: mongoose.Schema.Types.Mixed, + }) + option: PlatPulOption +} + +export const PostMediaContainerSchema = SchemaFactory.createForClass(PostMediaContainer) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/publishTask.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/publishTask.schema.ts new file mode 100644 index 000000000..0205d2b18 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/publishTask.schema.ts @@ -0,0 +1,161 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import mongoose from 'mongoose' +import { PlatPulOption } from '../../../core/publish/common' +import { BaseTemp } from './time.tamp' + +export enum PublishType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', +} + +export enum PublishStatus { + FAILED = -1, // 发布失败 + WaitingForPublish = 0, // 未发布 + PUBLISHED = 1, // 已发布 + PUBLISHING = 2, // 发布中 +} + +@Schema({ + collection: 'publishTask', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class PublishTask extends BaseTemp { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: false, + }) + flowId?: string // 前端传入的流水ID + + @Prop({ + required: false, + type: String, + }) + userTaskId?: string // 用户任务ID + + @Prop({ + required: false, + type: String, + }) + taskId?: string // 任务ID + + @Prop({ + required: false, + type: String, + }) + taskMaterialId?: string // 任务素材ID + + @Prop({ + required: true, + enum: PublishType, + }) + type: PublishType + + @Prop({ + required: false, + }) + title?: string + + @Prop({ + required: false, + default: '', + }) + desc?: string // 主要内容 + + @Prop({ + required: true, + }) + accountId: string + + // 话题 + @Prop({ + required: true, + type: [String], + }) + topics: string[] + + @Prop({ + required: true, + }) + accountType: AccountType + + @Prop({ + required: true, + }) + uid: string + + @Prop({ + required: false, + }) + videoUrl?: string + + @Prop({ + required: false, + }) + coverUrl?: string + + // 图片列表 + @Prop({ + required: false, + type: [String], + }) + imgUrlList?: string[] + + @Prop({ + required: true, + type: Date, + index: true, + }) + publishTime: Date + + @Prop({ + required: true, + enum: PublishStatus, + default: PublishStatus.WaitingForPublish, + }) + status: PublishStatus + + // 队列 ID + @Prop({ + required: false, + }) + queueId?: string + + // 此任务是否进入队列 + @Prop({ + required: true, + default: false, + }) + inQueue: boolean + + // 错误信息 + @Prop({ + required: false, + }) + errorMsg?: string + + /** + * 任意对象值 + * bilibili: { + * tid: number; 分区 + * copyright: number; 1-原创,2-转载(转载时source必填) + * source: string; 如果copyright为转载,则此字段表示转载来源; + * } + */ + @Prop({ + required: false, + type: mongoose.Schema.Types.Mixed, + }) + option?: PlatPulOption +} + +export const PublishTaskSchema = SchemaFactory.createForClass(PublishTask) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/replyCommentRecord.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/replyCommentRecord.schema.ts new file mode 100644 index 000000000..68f7b2ad2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/replyCommentRecord.schema.ts @@ -0,0 +1,71 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 回复评论的记录 replyCommentRecord + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { BaseTemp } from './time.tamp' + +@Schema({ + collection: 'replyCommentRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class ReplyCommentRecord extends BaseTemp { + id: string + + @Prop({ + required: true, + index: true, + type: String, + }) + userId: string + + @Prop({ + required: true, + index: true, + type: String, + }) + accountId: string + + // 作品ID + @Prop({ + index: true, + type: String, + }) + worksId?: string + + @Prop({ + required: true, + enum: AccountType, + }) + type: AccountType + + @Prop({ + required: true, + index: true, + type: String, + }) + commentId: string + + @Prop({ + required: true, + index: true, + type: String, + }) + commentContent: string + + @Prop({ + required: true, + index: true, + type: String, + }) + replyContent: string +} + +export const ReplyCommentRecordSchema = SchemaFactory.createForClass(ReplyCommentRecord) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKey.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKey.schema.ts new file mode 100644 index 000000000..13fce49f3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKey.schema.ts @@ -0,0 +1,41 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { BaseTemp } from './time.tamp' + +export enum SkKeyStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +@Schema({ + collection: 'skKey', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class SkKey extends BaseTemp { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + key: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + required: true, + default: SkKeyStatus.NORMAL, + }) + status: SkKeyStatus // 登录状态,用于判断是否失效 +} + +export const SkKeySchema = SchemaFactory.createForClass(SkKey) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKeyRefAccount.schema.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKeyRefAccount.schema.ts new file mode 100644 index 000000000..48ffba395 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/skKeyRefAccount.schema.ts @@ -0,0 +1,38 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { BaseTemp } from './time.tamp' + +@Schema({ + collection: 'skKeyRefAccount', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class SkKeyRefAccount extends BaseTemp { + id: string + + @Prop({ + required: true, + index: true, + }) + key: string + + @Prop({ + required: true, + index: true, + }) + accountId: string + + @Prop({ + required: true, + enum: AccountType, + }) + accountType: AccountType + + // 联合唯一索引定义 + static get indexes() { + return [{ key: 1, accountId: 1 }] + } +} + +export const SkKeyRefAccountSchema = SchemaFactory.createForClass(SkKeyRefAccount) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/time.tamp.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/time.tamp.ts new file mode 100644 index 000000000..7b949648f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/database/schema/time.tamp.ts @@ -0,0 +1,11 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-02-22 12:37:22 + * @LastEditors: nevin + * @Description: 时间模板 + */ +export class BaseTemp { + createdAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/constants.ts new file mode 100644 index 000000000..20381eb19 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/constants.ts @@ -0,0 +1,34 @@ +export const FacebookOAuth2Config = { + pkce: false, + shortLived: true, + apiBaseUrl: 'https://graph.facebook.com/', + authURL: 'https://www.facebook.com/v23.0/dialog/oauth', + accessTokenURL: 'https://graph.facebook.com/v23.0/oauth/access_token', + longLivedAccessTokenURL: + 'https://graph.facebook.com/v23.0/oauth/access_token', + refreshTokenURL: + 'https://graph.facebook.com/v23.0/oauth/access_token', + // see https://developers.facebook.com/docs/graph-api/overview/#me + userProfileURL: + 'https://graph.facebook.com/me?fields=id,first_name,last_name,middle_name,name,name_format,picture,short_name', + pageAccountURL: 'https://graph.facebook.com/v23.0/me/accounts', + + requestAccessTokenMethod: 'POST', + defaultScopes: [ + // see https://developers.facebook.com/docs/permissions + 'public_profile', + 'pages_show_list', + 'pages_manage_posts', + 'pages_read_engagement', + // 'pages_read_user_content', + // 'pages_manage_engagement', + // 'pages_manage_metadata', + // 'read_insights', + // 'pages_manage_ads', + ], + longLivedGrantType: 'fb_exchange_token', + longLivedParamsMap: { + access_token: 'fb_exchange_token', + }, + scopesSeparator: ' ', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook-api.exception.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook-api.exception.ts new file mode 100644 index 000000000..2c41891b2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook-api.exception.ts @@ -0,0 +1,96 @@ +import { AxiosError } from 'axios' + +export interface FacebookRawError { + message: string + type: string + code: number + fbtrace_id: string +} + +export interface NormalizedFacebookError { + status?: number + raw?: FacebookRawError + isNetwork: boolean + isAxios: boolean + original: unknown +} + +export interface FacebookApiExceptionMeta { + url?: string + method?: string + operation?: string + attempt?: number + extra?: Record +} + +export class FacebookApiException extends Error { + readonly operation: string + readonly normalized: NormalizedFacebookError + readonly meta?: FacebookApiExceptionMeta + + constructor( + operation: string, + normalized: NormalizedFacebookError, + meta?: FacebookApiExceptionMeta, + ) { + super(FacebookApiException.buildMessage(operation, normalized)) + this.name = 'FacebookApiException' + this.operation = operation + this.normalized = normalized + this.meta = meta + } + + static buildMessage(op: string, n: NormalizedFacebookError): string { + if (n.raw) { + const { message, code } = n.raw + return `${op} failed: ${message} (code=${code})` + } + if (n.isNetwork) { + return `${op} failed: network error` + } + return `${op} failed: unexpected error` + } + + toJSON() { + return { + name: this.name, + message: this.message, + operation: this.operation, + status: this.normalized.status, + facebook: this.normalized.raw && { + code: this.normalized.raw.code, + type: this.normalized.raw.type, + trace: this.normalized.raw.fbtrace_id, + }, + meta: this.meta, + } + } + + static fromAxiosError( + operation: string, + error: AxiosError, + meta?: FacebookApiExceptionMeta, + ): FacebookApiException { + const normalized: NormalizedFacebookError = { + status: error.response?.status, + raw: (error.response?.data as { error?: FacebookRawError } | undefined)?.error, + isNetwork: !!error.isAxiosError && !error.response, + isAxios: true, + original: error, + } + return new FacebookApiException(operation, normalized, meta) + } +} + +export function normalizeFacebookError(e: unknown): NormalizedFacebookError { + const err = e as { isAxiosError?: boolean, response?: { status?: number, data?: { error?: FacebookRawError } } } + const isAxios = !!err?.isAxiosError + const raw = err?.response?.data?.error + return { + status: err?.response?.status, + raw, + isNetwork: isAxios && !err?.response, + isAxios, + original: e, + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.enum.ts new file mode 100644 index 000000000..b6b33bdb2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.enum.ts @@ -0,0 +1,8 @@ +// see https://developers.facebook.com/docs/graph-api/guides/upload/ +export enum FacebookMediaType { + JPEG = 'image/jpeg', + JPG = 'image/jpg', + PNG = 'image/png', + VIDEO = 'video/mp4', + PDF = 'application/pdf', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.interfaces.ts new file mode 100644 index 000000000..1bd9a51db --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.interfaces.ts @@ -0,0 +1,393 @@ +export interface FacebookInitialVideoUploadRequest { + upload_phase: 'start' | 'transfer' | 'finish' | 'cancel' + file_size: number + published: boolean +} + +export interface FacebookInitialVideoUploadResponse { + video_id: string + upload_session_id: string + start_offset: number + end_offset: number +} + +export interface ChunkedVideoUploadRequest { + published: boolean + upload_phase: 'start' | 'transfer' | 'finish' | 'cancel' + upload_session_id: string + start_offset: number + end_offset: number + video_file_chunk: Buffer +} + +export interface finalizeVideoUploadRequest { + upload_phase: 'start' | 'transfer' | 'finish' | 'cancel' + upload_session_id: string + published: boolean +} + +export interface finalizeVideoUploadResponse { + success: boolean +} + +export interface ChunkedVideoUploadResponse { + start_offset: number + end_offset: number +} + +export interface ResumeVideoUploadRequest { + uploadSessionId: string +} + +export interface FacebookPost { + page_id?: string + content_category?: string + content_tags?: string[] + custom_labels?: string[] + direct_share_status?: number + embeddable?: boolean +} + +export interface PublishFeedPostRequest { + message: string + published: boolean + link?: string +} + +export interface publishFeedPostResponse { + id: string +} + +export interface PublishVideoPostRequest { + description?: string + title?: string + crossposted_video_id: string + published: boolean +} + +export interface publishVideoPostResponse { + id: string +} + +export interface ResumeFileUploadResponse { + id: string + file_offset: number +} + +export interface PublishVideoForPageRequest { + file_url: string + published: boolean + description?: string + title?: string +} + +export interface PublishVideoForPageResponse { + id: string +} + +export interface PublishMediaPostResponse { + id: string + post_id?: string +} + +export interface UploadPhotoResponse { + id: string + post_id: string +} + +export interface PageAccessTokenData { + access_token: string + name: string + id: string +} + +export interface PageAccessTokenResponse { + data: PageAccessTokenData[] +} + +export interface FacebookObjectInfo { + status: { + video_status?: string + uploading_phase?: { + status: string + } + processing_phase?: { + status: string + } + publishing_phase?: { + status: string + } + } + id: string +} + +export interface FacebookInsightsValue { + message_type: string + messaging_channel: string + campaign_id: string + earning_source: string + start_time: string + end_time: string + engagement_source: string + monetization_tool: string + recurring_notifications_entry_point: string + recurring_notifications_frequency: string + recurring_notifications_topic: string + value: number +} + +export interface FacebookInsightsResult { + id: string + name: string + description: string + description_from_api_docs: string + period: string + title: string + values: FacebookInsightsValue[] +} + +export interface FacebookPaginationCursor { + before: string + after: string +} + +export interface FacebookPagination { + cursors?: FacebookPaginationCursor + next: string + previous: string +} + +export interface FacebookInsightsRequest { + metric: string + period?: 'day' | 'week' | 'days_28' | 'month' | 'lifetime' | 'total_over_range' + // enum{today, yesterday, this_month, last_month, this_quarter, maximum, data_maximum, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, last_week_mon_sun, last_week_sun_sat, last_quarter, last_year, this_week_mon_today, this_week_sun_today, this_year} + date_preset?: 'today' | 'yesterday' | 'this_month' + | 'last_month' | 'this_quarter' | 'maximum' + | 'data_maximum' | 'last_3d' | 'last_7d' + | 'last_14d' | 'last_28d' | 'last_30d' + | 'last_90d' | 'last_week_mon_sun' + | 'last_week_sun_sat' | 'last_quarter' + | 'last_year' | 'this_week_mon_today' + | 'this_week_sun_today' | 'this_year' + show_description_from_api_docs?: boolean + since?: string // ISO date string + until?: string // ISO date string +} + +export interface FacebookInsightsResponse { + data: FacebookInsightsResult[] + paging: FacebookPagination +} + +export interface FacebookPageDetailRequest { + fields: string +} + +export interface FacebookPagePicture { + data: { + url: string + } +} +export interface FacebookPageDetailResponse { + id: string + fan_count: number + followers_count: number + picture?: FacebookPagePicture +} + +export interface FacebookPublishedPostRequest { + summary?: boolean +} + +export interface FacebookPagePost { + id: string + created_time: string + message: string +} + +export interface FacebookPublishedPostSummary { + total_count: number +} + +export interface FacebookPublishedPostResponse { + data: FacebookPagePost[] + paging: FacebookPagination + summary?: FacebookPublishedPostSummary +} + +export interface FacebookPostDetailRequest { + field: string + summary?: boolean +} + +export interface FacebookPagePostAttachmentCover { + height: number + src: string + width: number +} +export interface FacebookPagePostAttachmentMediaTarget { + id: string + url: string +} + +export interface FacebookPagePostAttachmentMedia { + image: FacebookPagePostAttachmentCover + target: FacebookPagePostAttachmentMediaTarget +} +export interface FacebookPagePostAttachment { + media: FacebookPagePostAttachmentMedia + type: string + url: string +} +export interface FacebookPostDetail { + id: string + created_time: string + message?: string + is_expired?: boolean + is_published?: boolean + permalink_url?: string + attachments?: { data: FacebookPagePostAttachment[] } + likes?: { count: number } + comments?: { count: number } + shares?: { count: number } +} + +export interface FacebookPagePostRequest { + fields: string + limit?: number + after?: string + before?: string + next?: string + previous?: string +} + +export interface FacebookPostDetailResponse { + data: FacebookPostDetail[] + paging?: FacebookPagination +} + +export interface FacebookPostEdgesRequest { + summary: boolean + type?: 'LIKE' +} + +export interface FacebookPostEdgesResponse { + summary?: { + total_count: number + } +} + +export interface FacebookReelRequest { + upload_phase: 'start' | 'finish' + video_state?: 'draft' | 'published' | 'scheduled' + video_id?: string + title?: string + description?: string + scheduled_publish_time?: number +} + +export interface FacebookReelResponse { + video_id?: string + upload_url?: string + success?: boolean + message?: string + post_id?: string +} + +export interface FacebookReelUploadRequest { + offset: number + file_size: number + file: Buffer +} + +export interface FacebookReelUploadResponse { + success: boolean +} + +export interface FacebookPhotoStoryRequest { + photo_id: string +} + +export interface FacebookPostCommentsRequest { + filter?: 'stream' | 'toplevel' + order?: 'chronological' | 'reverse_chronological' + since?: string + summary?: 'order' | 'total_count' | 'can_comment' + fields: string + before?: string + after?: string +} + +export interface FacebookPostComment { + id: string + message: string + created_time: string + from: { + name: string + id: string + picture: { data: { url: string } } + } + comment_count: number +} + +export interface FacebookPostCommentsResponse { + data: FacebookPostComment[] + paging: FacebookPagination + summary?: { + order: string + total_count: number + can_comment: boolean + } +} + +export interface FacebookCommonError { + message: string + type: string + code: number + fbtrace_id: string +} +export interface Location { + city?: string + country?: string + latitude?: number + longitude?: number + state?: string + street?: string + zip?: string +} + +export interface FacebookPageInfo { + id: string + name: string + location?: Location + link?: string +} + +export interface FacebookSearchPagesResponse { + data: FacebookPageInfo[] + paging?: FacebookPagination + error?: FacebookCommonError +} + +export interface FacebookSearchPagesRequest { + q: string + fields: string +} + +export interface FacebookPostAttachmentMedia { + source?: string + image: { + src: string + width: number + height: number + } +} + +export interface FacebookPostAttachment { + type: 'photo' | 'video' | 'video_inline' + media: FacebookPostAttachmentMedia +} + +export interface FacebookPostAttachmentsResponse { + data?: FacebookPostAttachment[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.module.ts new file mode 100644 index 000000000..758ec7c94 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { FacebookService } from './facebook.service' + +@Module({ + imports: [], + providers: [FacebookService], + exports: [FacebookService], +}) +export class FacebookModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.operations.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.operations.ts new file mode 100644 index 000000000..a67099396 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.operations.ts @@ -0,0 +1,46 @@ +// Operation constants for FacebookService (human-readable lowercase labels) +// Chosen scheme: const object + literal type (plan option 2) +export const FacebookOperation = { + REFRESH_OAUTH_CREDENTIAL: 'refresh oauth credential', + GET_PAGE_ACCESS_TOKEN: 'get page access token', + + INIT_VIDEO_UPLOAD: 'init video upload', + CHUNKED_VIDEO_UPLOAD: 'chunked video upload', + FINALIZE_VIDEO_UPLOAD: 'finalize video upload', + PUBLISH_VIDEO_POST: 'publish video post', + PUBLISH_VIDEO_BY_IMAGE_URL: 'publish video by image url', + + UPLOAD_PHOTO_BY_URL: 'upload photo by url', + UPLOAD_PHOTO_BY_FILE: 'upload photo by file', + PUBLISH_SINGLE_PHOTO_POST: 'publish single photo post', + PUBLISH_MULTIPLE_PHOTO_POST: 'publish multiple photo post', + PUBLISH_FEED_POST: 'publish feed post', + + GET_OBJECT_INFO: 'get object info', + GET_PAGE_INSIGHTS: 'get page insights', + GET_OBJECT_INSIGHTS: 'get object insights', + GET_PAGE_DETAILS: 'get page details', + GET_PAGE_PUBLISHED_POSTS: 'get page published posts', + GET_PAGE_POST_DETAILS: 'get page post details', + + GET_POST_COMMENTS: 'get post comments', + GET_POST_REACTIONS: 'get post reactions', + FETCH_PAGE_POSTS: 'fetch page posts', + FETCH_OBJECT_COMMENTS: 'fetch object comments', + PUBLISH_PLAINTEXT_COMMENT: 'publish plaintext comment', + + INIT_REEL_UPLOAD: 'init reel upload', + UPLOAD_REEL_CHUNK: 'upload reel chunk', + PUBLISH_REEL_POST: 'publish reel post', + + INIT_VIDEO_STORY_UPLOAD: 'init video story upload', + UPLOAD_VIDEO_STORY_CHUNK: 'upload video story chunk', + PUBLISH_VIDEO_STORY_POST: 'publish video story post', + PUBLISH_PHOTO_STORY_POST: 'publish photo story post', + + SEARCH_PAGES: 'search pages', + FETCH_POST_ATTACHMENT: 'fetch post attachment', + DELETE_POST: 'delete post', +} as const + +export type FacebookOperationLabel = typeof FacebookOperation[keyof typeof FacebookOperation] diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.service.ts new file mode 100644 index 000000000..c05e226ea --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/facebook/facebook.service.ts @@ -0,0 +1,667 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' +import { MetaOAuthLongLivedCredential } from '../../core/plat/meta/meta.interfaces' +import { FacebookOAuth2Config } from './constants' +import { FacebookApiException, normalizeFacebookError } from './facebook-api.exception' +import { + ChunkedVideoUploadRequest, + ChunkedVideoUploadResponse, + FacebookInitialVideoUploadRequest, + FacebookInitialVideoUploadResponse, + FacebookInsightsRequest, + FacebookInsightsResponse, + FacebookObjectInfo, + FacebookPageDetailRequest, + FacebookPageDetailResponse, + FacebookPagePostRequest, + FacebookPostAttachmentsResponse, + FacebookPostCommentsRequest, + FacebookPostCommentsResponse, + FacebookPostDetail, + FacebookPostDetailRequest, + FacebookPostDetailResponse, + FacebookPostEdgesRequest, + FacebookPostEdgesResponse, + FacebookPublishedPostRequest, + FacebookPublishedPostResponse, + FacebookReelRequest, + FacebookReelResponse, + FacebookReelUploadRequest, + FacebookReelUploadResponse, + FacebookSearchPagesRequest, + FacebookSearchPagesResponse, + finalizeVideoUploadRequest, + finalizeVideoUploadResponse, + PageAccessTokenResponse, + PublishFeedPostRequest, + PublishMediaPostResponse, + PublishVideoForPageRequest, + PublishVideoForPageResponse, + PublishVideoPostRequest, + publishVideoPostResponse, + UploadPhotoResponse, +} from './facebook.interfaces' +import { FacebookOperation } from './facebook.operations' + +@Injectable() +export class FacebookService { + private readonly logger = new Logger(FacebookService.name) + private readonly clientSecret: string = config.oauth.facebook.clientSecret + private readonly clientId: string = config.oauth.facebook.clientId + private readonly longLivedAccessTokenURL: string = FacebookOAuth2Config.longLivedAccessTokenURL + + private readonly apiHost: string = 'https://graph.facebook.com/' + private readonly apiBaseUrl: string = 'https://graph.facebook.com/v23.0' + + constructor() { } + + private async request( + url: string, + config: AxiosRequestConfig = {}, + options: { operation?: string } = {}, + ): Promise { + const operation = options.operation || 'facebook request' + this.logger.debug(`[FB:${operation}] Request -> ${url} ${config.method || 'GET'} ${config.params ? `params=${JSON.stringify(config.params)}` : ''}`) + try { + const response: AxiosResponse = await axios(url, config) + this.logger.debug(`[FB:${operation}] Response <- ${url} status=${response.status} data=${JSON.stringify(response.data)}`) + return response.data + } + catch (error: unknown) { + const normalized = normalizeFacebookError(error) + const status = normalized.status + const code = normalized.raw?.code + const message = normalized.raw?.message || (error as Error)?.message + this.logger.error(`[FB:${operation}] Failed url=${url} status=${status} code=${code} msg=${message}`) + throw new FacebookApiException(operation, normalized, { url, method: config.method }) + } + } + + async refreshOAuthCredential(refresh_token: string) { + const config: AxiosRequestConfig = { + method: 'GET', + params: { + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'fb_exchange_token', + fb_exchange_token: refresh_token, + }, + } + + return await this.request(this.longLivedAccessTokenURL, config) + } + + async initVideoUpload(pageId: string, pageAccessToken: string, req: FacebookInitialVideoUploadRequest): Promise { + const formData = new FormData() + formData.append('upload_phase', req.upload_phase) + formData.append('file_size', req.file_size.toString()) + formData.append('published', req.published.toString()) + + const url = `${this.apiBaseUrl}/${pageId}/videos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: formData, + } + const response = await this.request( + url, + config, + { operation: FacebookOperation.INIT_VIDEO_UPLOAD }, + ) + return response + } + + async getPageAccessToken(accessToken: string): Promise { + const url = `${this.apiBaseUrl}/me/accounts` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return this.request(url, config, { operation: FacebookOperation.GET_PAGE_ACCESS_TOKEN }) + } + + async chunkedVideoUploadRequest( + pageId: string, + pageAccessToken: string, + req: ChunkedVideoUploadRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/videos` + const formData = new FormData() + formData.append('video_file_chunk', new Blob([req.video_file_chunk])) + formData.append('upload_phase', req.upload_phase) + formData.append('upload_session_id', req.upload_session_id) + formData.append('start_offset', req.start_offset.toString()) + formData.append('end_offset', req.end_offset.toString()) + formData.append('published', req.published.toString()) + + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: formData, + } + return this.request(url, config, { operation: FacebookOperation.CHUNKED_VIDEO_UPLOAD }) + } + + async finalizeVideoUpload( + pageId: string, + pageAccessToken: string, + req: finalizeVideoUploadRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/videos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.FINALIZE_VIDEO_UPLOAD }) + } + + // https://developers.facebook.com/docs/graph-api/reference/page/videos/#Creating + // https://developers.facebook.com/docs/graph-api/reference/video/ + // immediately publish a video post + // see https://developers.facebook.com/docs/graph-api/reference/page/videos/?locale=en_US#Creating + // https://developers.facebook.com/docs/pages-api/posts#publish-a-video + // https://stackoverflow.com/questions/47284140/facebook-graph-api-publish-post-with-multiple-videos-and-photos + async publishVideoPost( + pageId: string, + pageAccessToken: string, + req: PublishVideoPostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/videos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_VIDEO_POST }) + } + + async publishVideoByImageURL( + pageId: string, + pageAccessToken: string, + req: PublishVideoForPageRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/videos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_VIDEO_BY_IMAGE_URL }) + } + + // upload a photo to a page by image URL + // see https://developers.facebook.com/docs/graph-api/reference/page/photos/#upload + async uploadPhotoPostByImgURL( + pageId: string, + pageAccessToken: string, + imageURL: string, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/photos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: { + published: false, + url: imageURL, + }, + } + return this.request(url, config, { operation: FacebookOperation.UPLOAD_PHOTO_BY_URL }) + } + + // upload a photo to a page by file + // see https://developers.facebook.com/docs/graph-api/reference/page/photos/#upload + // https://stackoverflow.com/questions/50484978/posting-multiple-photo-as-one-batch-to-facebook-page + // https://community.n8n.io/t/upload-multiple-images-to-facebook-page-in-a-single-post/15389 + async uploadPostPhotoByFile( + pageId: string, + pageAccessToken: string, + file: Blob, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/photos` + const formData = new FormData() + formData.append('published', 'false') + formData.append('source', file) // assuming JPEG, adjust as needed + + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'multipart/form-data', + }, + data: formData, + } + + return this.request(url, config, { operation: FacebookOperation.UPLOAD_PHOTO_BY_FILE }) + } + + // immediately publish a single photo post by image URL + async publishSinglePhotoPostByImgURL( + pageId: string, + pageAccessToken: string, + imageUrl: string, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/photos` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: { + url: imageUrl, + }, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_SINGLE_PHOTO_POST }) + } + + // immediately publish multiple photos as a single post + // first upload the photos to get their IDs, then use those IDs to create a post + // see https://developers.facebook.com/docs/graph-api/reference/page/photos/#upload + async publishMultiplePhotoPost( + pageId: string, + pageAccessToken: string, + imageIDList: string[], + caption?: string, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/feed` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: { + attached_media: imageIDList.map(id => ({ media_fbid: id })), + message: caption || '', + published: true, + }, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_MULTIPLE_PHOTO_POST }) + } + + // https://developers.facebook.com/docs/graph-api/reference/page/photos/#Creating + // https://developers.facebook.com/docs/graph-api/reference/v23.0/page/feed#publish + // https://developers.facebook.com/docs/graph-api/reference/page/photos/#upload + async publishFeedPost( + pageId: string, + pageAccessToken: string, + req: PublishFeedPostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/feed` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-Type': 'application/json', + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_FEED_POST }) + } + + async getObjectInfo( + pageAccessToken: string, + objectId: string, + fields?: string, + ): Promise { + const url = `${this.apiBaseUrl}/${objectId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + } + if (fields) { + config.params = { fields } + } + return this.request(url, config, { operation: FacebookOperation.GET_OBJECT_INFO }) + } + + async getPageInsights( + pageId: string, + pageAccessToken: string, + query: FacebookInsightsRequest, + requestURL?: string, + ): Promise { + const url = requestURL || `${this.apiBaseUrl}/${pageId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_PAGE_INSIGHTS }) + } + + async getPageDetails( + pageId: string, + pageAccessToken: string, + query: FacebookPageDetailRequest, + ): Promise { + const url = `${this.apiHost}/${pageId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_PAGE_DETAILS }) + } + + async getPagePublishedPosts( + pageId: string, + pageAccessToken: string, + query: FacebookPublishedPostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/published_posts` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_PAGE_PUBLISHED_POSTS }) + } + + async getPagePostDetails( + postId: string, + pageAccessToken: string, + query: FacebookPostDetailRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return await this.request(url, config, { operation: FacebookOperation.GET_PAGE_POST_DETAILS }) + } + + async getPostComments( + postId: string, + pageAccessToken: string, + query: FacebookPostEdgesRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}/comments` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_POST_COMMENTS }) + } + + async getPostReactions( + postId: string, + pageAccessToken: string, + query: FacebookPostEdgesRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}/reactions` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_POST_REACTIONS }) + } + + // get insights for a specific object (like a post or page) + // see https://developers.facebook.com/docs/graph-api/reference/post/insights/ + // post views and likes query: metric=post_reactions_like_total,post_video_views&period=lifetime + async getFacebookObjectInsights( + objectId: string, + pageAccessToken: string, + query: FacebookInsightsRequest, + requestURL?: string, + ): Promise { + const url = requestURL || `${this.apiBaseUrl}/${objectId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.GET_OBJECT_INSIGHTS }) + } + + async initReelUpload( + pageId: string, + pageAccessToken: string, + req: FacebookReelRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/video_reels` + + const formData = new FormData() + formData.append('upload_phase', req.upload_phase) + + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: formData, + } + return this.request(url, config, { operation: FacebookOperation.INIT_REEL_UPLOAD }) + } + + async uploadReel( + pageAccessToken: string, + uploadURL: string, + req: FacebookReelUploadRequest, + ): Promise { + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-type': 'application/octet-stream', + 'offset': req.offset.toString(), + 'file_size': req.file_size.toString(), + }, + data: req.file, + } + return this.request(uploadURL, config, { operation: FacebookOperation.UPLOAD_REEL_CHUNK }) + } + + async publishReelPost( + pageId: string, + pageAccessToken: string, + req: FacebookReelRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/video_reels` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_REEL_POST }) + } + + async initVideoStoryUpload( + pageId: string, + pageAccessToken: string, + req: FacebookReelRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/video_stories` + const formData = new FormData() + formData.append('upload_phase', req.upload_phase) + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: formData, + } + return this.request(url, config, { operation: FacebookOperation.INIT_VIDEO_STORY_UPLOAD }) + } + + async uploadVideoStory( + pageAccessToken: string, + uploadURL: string, + req: FacebookReelUploadRequest, + ): Promise { + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${pageAccessToken}`, + 'Content-type': 'application/octet-stream', + 'offset': req.offset.toString(), + 'file_size': req.file_size.toString(), + }, + data: req.file, + } + return this.request(uploadURL, config, { operation: FacebookOperation.UPLOAD_VIDEO_STORY_CHUNK }) + } + + async publishVideoStoryPost( + pageId: string, + pageAccessToken: string, + req: FacebookReelRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/video_stories` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: req, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_VIDEO_STORY_POST }) + } + + async publishPhotoStoryPost( + pageId: string, + pageAccessToken: string, + photo_id: string, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/photo_stories` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: { photo_id }, + } + return this.request(url, config, { operation: FacebookOperation.PUBLISH_PHOTO_STORY_POST }) + } + + async fetchPagePosts( + pageId: string, + pageAccessToken: string, + query: FacebookPagePostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${pageId}/feed` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return await this.request(url, config, { operation: FacebookOperation.FETCH_PAGE_POSTS }) + } + + async fetchObjectComments( + objectId: string, + pageAccessToken: string, + query: FacebookPostCommentsRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${objectId}/comments` + const config: AxiosRequestConfig = { + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.FETCH_OBJECT_COMMENTS }) + } + + async publishPlaintextComment( + objectId: string, + pageAccessToken: string, + message: string, + ): Promise<{ id: string }> { + const url = `${this.apiBaseUrl}/${objectId}/comments` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + data: { message }, + } + return this.request<{ id: string }>(url, config, { operation: FacebookOperation.PUBLISH_PLAINTEXT_COMMENT }) + } + + async searchPages( + pageAccessToken: string, + query: FacebookSearchPagesRequest, + ): Promise { + const url = `${this.apiBaseUrl}/pages/search` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + params: query, + } + return this.request(url, config, { operation: FacebookOperation.SEARCH_PAGES }) + } + + async fetchPostAttachments( + postId: string, + pageAccessToken: string, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}/attachments` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + } + return this.request(url, config, { operation: FacebookOperation.FETCH_POST_ATTACHMENT }) + } + + async deletePost( + postId: string, + pageAccessToken: string, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${pageAccessToken}`, + }, + } + await this.request(url, config, { operation: FacebookOperation.DELETE_POST }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/index.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/index.ts new file mode 100644 index 000000000..e1ddd7b2f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/index.ts @@ -0,0 +1 @@ +export * from './database' diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/constants.ts new file mode 100644 index 000000000..a5c788638 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/constants.ts @@ -0,0 +1,27 @@ +export const InstagramOAuth2Config = { + pkce: false, + shortLived: true, + apiBaseUrl: 'https://graph.instagram.com', + authURL: 'https://api.instagram.com/oauth/authorize', + // short-lived access token, expires in 1 hour + accessTokenURL: 'https://api.instagram.com/oauth/access_token', + pageAccountURL: '', + longLivedAccessTokenURL: 'https://graph.instagram.com/access_token', + // refresh long-lived access token: https://developers.facebook.com/docs/instagram-platform/reference/refresh_access_token + refreshTokenURL: 'https://graph.instagram.com/refresh_access_token', + // see https://developers.facebook.com/docs/instagram-platform/reference/me/ + userProfileURL: + 'https://graph.instagram.com/v23.0/me?fields=id,name,username,profile_picture_url', + requestAccessTokenMethod: 'POST', + + defaultScopes: [ + // see https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login + 'instagram_business_basic', + 'instagram_business_content_publish', + ], + longLivedGrantType: 'ig_exchange_token', + longLivedParamsMap: { + access_token: 'access_token', + }, + scopesSeparator: ' ', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram-api.exception.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram-api.exception.ts new file mode 100644 index 000000000..3ffe344d9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram-api.exception.ts @@ -0,0 +1,98 @@ +import { AxiosError } from 'axios' + +export interface InstagramRawError { + message: string + type: string + code: number + error_subcode?: number + fbtrace_id?: string +} + +export interface NormalizedInstagramError { + status?: number + raw?: InstagramRawError + isNetwork: boolean + isAxios: boolean + original: unknown +} + +export interface InstagramApiExceptionMeta { + url?: string + method?: string + operation?: string + extra?: Record +} + +export class InstagramApiException extends Error { + readonly operation: string + readonly normalized: NormalizedInstagramError + readonly meta?: InstagramApiExceptionMeta + + constructor( + operation: string, + normalized: NormalizedInstagramError, + meta?: InstagramApiExceptionMeta, + ) { + super(InstagramApiException.buildMessage(operation, normalized)) + this.name = 'InstagramApiException' + this.operation = operation + this.normalized = normalized + this.meta = meta + } + + static buildMessage(op: string, n: NormalizedInstagramError): string { + if (n.raw) { + const { message, code, error_subcode } = n.raw + const sub = error_subcode != null ? `, subcode=${error_subcode}` : '' + return `${op} failed: ${message} (code=${code}${sub})` + } + if (n.isNetwork) { + return `${op} failed: network error` + } + return `${op} failed: unexpected error` + } + + toJSON() { + return { + name: this.name, + message: this.message, + operation: this.operation, + status: this.normalized.status, + instagram: this.normalized.raw && { + code: this.normalized.raw.code, + subcode: this.normalized.raw.error_subcode, + type: this.normalized.raw.type, + trace: this.normalized.raw.fbtrace_id, + }, + meta: this.meta, + } + } + + static fromAxiosError( + operation: string, + error: AxiosError, + meta?: InstagramApiExceptionMeta, + ): InstagramApiException { + const normalized: NormalizedInstagramError = { + status: error.response?.status, + raw: (error.response?.data as { error?: InstagramRawError } | undefined)?.error, + isNetwork: !!error.isAxiosError && !error.response, + isAxios: true, + original: error, + } + return new InstagramApiException(operation, normalized, meta) + } +} + +export function normalizeInstagramError(e: unknown): NormalizedInstagramError { + const err = e as { isAxiosError?: boolean, response?: { status?: number, data?: { error?: InstagramRawError } } } + const isAxios = !!err?.isAxiosError + const raw = err?.response?.data?.error + return { + status: err?.response?.status, + raw, + isNetwork: isAxios && !err?.response, + isAxios, + original: e, + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.enum.ts new file mode 100644 index 000000000..b5f59fe58 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.enum.ts @@ -0,0 +1,87 @@ +export enum InstagramMediaType { + CAROUSEL = 'CAROUSEL', + REELS = 'REELS', + STORIES = 'STORIES', + VIDEO = 'VIDEO', + IMAGE = 'IMAGE', +} + +// see https://developers.facebook.com/docs/instagram-platform/api-reference/instagram-user/insights#metrics +export enum InstagramInsightsMetric { + ACCOUNTS_ENGAGED = 'accounts_engaged', + COMMENTS = 'comments', + ENGAGED_AUDIENCE_DEMOGRAPHICS = 'engaged_audience_demographics', + FOLLOWS_AND_UNFOLLOWS = 'follows_and_unfollows', + IMPRESSIONS = 'impressions', + LIKES = 'likes', + PROFILE_LINKS_TAPS = 'profile_links_taps', + REACH = 'reach', + REPLIES = 'replies', + SAVED = 'saved', + SHARES = 'shares', + TOTAL_INTERACTIONS = 'total_interactions', + VIEWS = 'views', +} + +export enum InstagramInsightsMetricType { + TIME_SERIES = 'time_series', // Tells the API to aggregate results by time period. + TOTAL_VALUE = 'total_value', +} + +// Designates how to break down result set into subsets. +export enum InstagramInsightsResultBreakdown { + CONTACT_BUTTON_TYPE = 'contact_button_type', + FOLLOW_TYPE = 'follow_type', + MEDIA_PRODUCT_TYPE = 'media_product_type', + AGE = 'age', + CITY = 'city', + COUNTRY = 'country', + GENDER = 'gender', +} + +export enum InstagramInsightsPeriod { + DAY = 'day', // 1 day + LIFETIME = 'lifetime', // All time +} + +export enum InstagramInsightsMetricTimeframe { + LAST_14_DAYS = 'last_14_days', + LAST_30_DAYS = 'last_30_days', + LAST_90_DAYS = 'last_90_days', + PREV_MONTH = 'prev_month', + THIS_MONTH = 'this_month', + THIS_WEEK = 'this_week', +} + +// see https://developers.facebook.com/docs/instagram-platform/reference/instagram-media/insights +export enum InstagramMediaInsightsMetric { + CLIPS_REPLAYS_COUNT = 'clips_replays_count', + COMMENTS = 'comments', + FOLLOWS = 'follows', + IG_REELS_AVG_WATCH_TIME = 'ig_reels_avg_watch_time', + IG_REELS_VIDEO_VIEW_TOTAL_TIME = 'ig_reels_video_view_total_time', + LIKES = 'likes', + NAVIGATION = 'navigation', + PROFILE_ACTIVITY = 'profile_activity', + PROFILE_VISITS = 'profile_visits', + REACH = 'reach', + REPLIES = 'replies', + SAVED = 'saved', + SHARES = 'shares', + TOTAL_INTERACTIONS = 'total_interactions', + VIEWS = 'views', +} + +export enum InstagramMediaInsightsResultBreakdown { + ACTION_TYPE = 'action_type', + STORY_NAVIGATION_ACTION_TYPE = 'story_navigation_action_type', +} + +export enum InstagramMediaInsightsPeriod { + DAY = 'day', + WEEK = 'week', + DAYS_28 = 'days_28', + MONTH = 'month', + LIFETIME = 'lifetime', + TOTAL_OVER_RANGE = 'total_over_range', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.interfaces.ts new file mode 100644 index 000000000..edc41134d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.interfaces.ts @@ -0,0 +1,198 @@ +import { InstagramInsightsMetricTimeframe, InstagramInsightsMetricType, InstagramInsightsResultBreakdown, InstagramMediaInsightsResultBreakdown, InstagramMediaType } from './instagram.enum' + +export interface ProductTag { + product_id: string + x: number + y: number +} + +export interface UserTag { + username: string + x: number + y: number +} + +export interface InstagramPost { + content_category?: string + alt_text?: string + caption?: string + collaborators?: string[] + cover_url?: string + image_url?: string + location_id?: string + product_tags?: ProductTag[] + user_tags?: UserTag[] +} + +export interface CreateMediaContainerRequest { + alt_text?: string + audio_name?: string + caption?: string + collaborators?: string[] + children?: string[] + cover_url?: string + image_url?: string + is_carousel_item?: boolean + location_id?: string + media_type?: InstagramMediaType + product_tags?: ProductTag[] + share_to_feed?: boolean + thumb_offset?: number + upload_type?: string + user_tags?: UserTag[] + video_url?: string +} + +export interface CreateMediaContainerResponse { + id: string + uri?: string +} + +export interface PublishMediaContainerRequest { + creation_id: string +} + +export interface PublishMediaContainerResponse { + id: string +} + +export interface ChunkedMediaUploadRequest extends CreateMediaContainerRequest { + file_size: string + offset: number + file: Buffer + ig_container_id: string + upload_uri: string +} + +export interface InstagramInsightsRequest { + metric: string + metric_type?: 'time_series' | 'total_value' + breakdown?: InstagramInsightsResultBreakdown + period?: 'day' | 'lifetime' + since?: number + until?: number + timeframe?: InstagramInsightsMetricTimeframe +} + +export interface InstagramInsightsBreakdownResult { + dimension_values: string[] + value: number +} + +export interface InstagramInsightsBreakdown { + dimension_keys: string[] + results: InstagramInsightsBreakdownResult[] +} +export interface InstagramInsightsValue { + value?: number + breakdowns?: InstagramInsightsBreakdown[] +} +export interface InstagramInsightsResult { + description: string + id: string + name: string + period: string + title: string + total_value?: InstagramInsightsValue[] + values: InstagramInsightsValue[] +} + +export interface InstagramPaginationCursor { + before: string + after: string +} + +export interface InstagramPagination { + cursors: InstagramPaginationCursor + next: string + previous: string +} +export interface InstagramInsightsResponse { + data: InstagramInsightsResult[] + paging: InstagramPagination +} + +export interface InstagramMediaInsightsRequest { + breakdown?: InstagramMediaInsightsResultBreakdown + metric: string + period?: 'day' | 'lifetime' | 'week' + metric_type?: InstagramInsightsMetricType + since?: number + until?: number + timeframe?: InstagramInsightsMetricTimeframe +} + +export interface InstagramObjectInfo { + id: string + status: string +} + +export interface InstagramUserInfoRequest { + fields: string +} + +export interface InstagramUserInfoResponse { + id: string + followers_count: number + follows_count: number + media_count: number +} + +export interface InstagramUserPostRequest { + fields: string + limit?: number + after?: string + before?: string + since?: number + until?: number +} + +export interface InstagramUserPost { + id: string + caption?: string + media_type: InstagramMediaType + media_url: string + permalink: string + thumbnail_url?: string + timestamp: string + username: string + like_count?: number + comments_count?: number + view_count?: number // just for reels +} + +export interface InstagramUserPostResponse { + data: InstagramUserPost[] + paging: InstagramPagination +} + +export interface IGPostCommentsRequest { + fields: string + after?: string + before?: string +} + +export interface IGPostComment { + id: string + text: string + username: string + timestamp: string + from?: { + username: string + id: string + } + replies: { + data: [{ + id: string + }] + } +} + +export interface IGCommentsResponse { + data: IGPostComment[] + paging: InstagramPagination +} + +export interface IGCommonResponse { + id: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.module.ts new file mode 100644 index 000000000..95e1653d8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { InstagramService } from './instagram.service' + +@Module({ + imports: [], + providers: [InstagramService], + exports: [InstagramService], +}) +export class InstagramModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.operations.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.operations.ts new file mode 100644 index 000000000..5d3798bc2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.operations.ts @@ -0,0 +1,19 @@ +export const InstagramOperation = { + REFRESH_OAUTH_CREDENTIAL: 'refresh_oauth_credential', + CREATE_MEDIA_CONTAINER: 'create_media_container', + CHUNKED_MEDIA_UPLOAD: 'chunked_media_upload', + PUBLISH_MEDIA_CONTAINER: 'publish_media_container', + GET_ACCOUNT_METRICS: 'get_account_metrics', + GET_MEDIA_INSIGHTS: 'get_media_insights', + GET_OBJECT_INFO: 'get_object_info', + GET_ACCOUNT_INSIGHTS: 'get_account_insights', + GET_ACCOUNT_INFO: 'get_account_info', + GET_USER_PROFILE: 'get_user_profile', + GET_USER_POSTS: 'get_user_posts', + FETCH_POST_COMMENTS: 'fetch_post_comments', + FETCH_COMMENT_REPLIES: 'fetch_comment_replies', + PUBLISH_COMMENT: 'publish_comment', + PUBLISH_SUB_COMMENT: 'publish_sub_comment', +} as const + +export type InstagramOperationLabel = typeof InstagramOperation[keyof typeof InstagramOperation] diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.service.ts new file mode 100644 index 000000000..ce5ca1559 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/instagram/instagram.service.ts @@ -0,0 +1,378 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' +import { MetaOAuthLongLivedCredential } from '../../core/plat/meta/meta.interfaces' +import { InstagramOAuth2Config } from './constants' +import { InstagramApiException, normalizeInstagramError } from './instagram-api.exception' +import { + ChunkedMediaUploadRequest, + CreateMediaContainerRequest, + CreateMediaContainerResponse, + IGCommentsResponse, + IGCommonResponse, + IGPostCommentsRequest, + InstagramInsightsRequest, + InstagramInsightsResponse, + InstagramMediaInsightsRequest, + InstagramObjectInfo, + InstagramUserInfoRequest, + InstagramUserInfoResponse, + InstagramUserPostRequest, + InstagramUserPostResponse, +} from './instagram.interfaces' +import { InstagramOperation } from './instagram.operations' + +@Injectable() +export class InstagramService { + private readonly logger = new Logger(InstagramService.name) + private readonly clientSecret: string = config.oauth.instagram.clientSecret + private readonly clientId: string = config.oauth.instagram.clientId + private readonly refreshAccessToken: string = InstagramOAuth2Config.refreshTokenURL + private readonly apiBaseUrl: string = InstagramOAuth2Config.apiBaseUrl + + private async request( + url: string, + config: AxiosRequestConfig = {}, + ctx: { operation: string }, + ): Promise { + const { operation } = ctx + this.logger.debug(`[IG:${operation}] request ${url} ${JSON.stringify({ method: config.method, params: config.params, headers: config.headers })}`) + try { + const response: AxiosResponse = await axios(url, config) + this.logger.debug(`[IG:${operation}] response ${url} status=${response.status} data=${JSON.stringify(response.data)}`) + return response.data + } + catch (error: unknown) { + const normalized = normalizeInstagramError(error) + if (normalized.raw) { + this.logger.error( + `[IG:${operation}] fail ${url} status=${normalized.status} code=${normalized.raw.code} sub=${normalized.raw.error_subcode} msg=${normalized.raw.message}`, + ) + } + else if (normalized.isNetwork) { + this.logger.error(`[IG:${operation}] network error ${url}`) + } + else { + this.logger.error(`[IG:${operation}] unexpected error ${url}: ${(error as any)?.message}`) + } + throw new InstagramApiException(operation, normalized, { url, method: config.method, operation }) + } + } + + async refreshOAuthCredential(refresh_token: string) { + const config: AxiosRequestConfig = { + method: 'GET', + params: { + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'ig_exchange_token', + access_token: refresh_token, + }, + } + return await this.request( + this.refreshAccessToken, + config, + { operation: InstagramOperation.REFRESH_OAUTH_CREDENTIAL }, + ) + } + + async createMediaContainer( + igUserId: string, + accessToken: string, + req: CreateMediaContainerRequest, + ): Promise { + const url = `${this.apiBaseUrl}/v23.0/${igUserId}/media` + const formData = new FormData() + Object.keys(req).forEach((key) => { + if (key !== 'children') { + formData.append(key, req[key]) + } + }) + if (req.children) { + req.children.forEach((child, index) => { + formData.append(`children[${index}]`, child) + }) + } + + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: formData, + } + + return await this.request( + url, + config, + { operation: InstagramOperation.CREATE_MEDIA_CONTAINER }, + ) + } + + async chunkedMediaUploadRequest( + accessToken: string, + req: ChunkedMediaUploadRequest, + ): Promise { + const url = req.upload_uri + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/octet-stream', + 'offset': `${req.offset || 0}`, + 'file_size': `${req.file_size}`, + }, + data: req.file, + } + return await this.request( + url, + config, + { operation: InstagramOperation.CHUNKED_MEDIA_UPLOAD }, + ) + } + + // https://developers.facebook.com/docs/instagram-platform/instagram-graph-api/reference/ig-user/media#creating + async publishMediaContainer( + igUserId: string, + accessToken: string, + creationId: string, + ): Promise { + const url = `${this.apiBaseUrl}/v23.0/${igUserId}/media_publish` + const formData = new FormData() + formData.append('creation_id', creationId) + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: formData, + } + return await this.request( + url, + config, + { operation: InstagramOperation.PUBLISH_MEDIA_CONTAINER }, + ) + } + + async getMetricsForAccount( + igUserId: string, + accessToken: string, + req: InstagramInsightsRequest, + ) { + const url = `${this.apiBaseUrl}/${igUserId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: req, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_ACCOUNT_METRICS }, + ) + } + + async getMediaInsights( + mediaId: string, + accessToken: string, + req: InstagramMediaInsightsRequest, + ) { + const url = `${this.apiBaseUrl}/${mediaId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: req, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_MEDIA_INSIGHTS }, + ) + } + + async getObjectInfo( + accessToken: string, + objectId: string, + fields?: string, + ): Promise { + const url = `${this.apiBaseUrl}/${objectId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + if (fields) { + config.params = { fields } + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_OBJECT_INFO }, + ) + } + + async getAccountInsights( + accessToken: string, + igUserId: string, + query: InstagramInsightsRequest, + requestURL?: string, + ): Promise { + const url = requestURL || `${this.apiBaseUrl}/${igUserId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_ACCOUNT_INSIGHTS }, + ) + } + + async getAccountInfo( + userId: string, + accessToken: string, + query: InstagramUserInfoRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${userId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_ACCOUNT_INFO }, + ) + } + + async getUserProfile( + accessToken: string, + query: InstagramUserInfoRequest, + ): Promise { + const url = `${this.apiBaseUrl}/me` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_USER_PROFILE }, + ) + } + + async getUserPosts( + accessToken: string, + userId: string, + query: InstagramUserPostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${userId}/media` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.GET_USER_POSTS }, + ) + } + + async fetchPostComments( + accessToken: string, + postId: string, + query: IGPostCommentsRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}/comments` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.FETCH_POST_COMMENTS }, + ) + } + + async fetchCommentReplies( + accessToken: string, + commentId: string, + query: IGPostCommentsRequest, + ): Promise { + const url = `${this.apiBaseUrl}/${commentId}/replies` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request( + url, + config, + { operation: InstagramOperation.FETCH_COMMENT_REPLIES }, + ) + } + + async publishComment( + accessToken: string, + postId: string, + message: string, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}/comments` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { message }, + } + return await this.request( + url, + config, + { operation: InstagramOperation.PUBLISH_COMMENT }, + ) + } + + async publishSubComment( + accessToken: string, + commentId: string, + message: string, + ): Promise { + const url = `${this.apiBaseUrl}/${commentId}/replies` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { message }, + } + return await this.request( + url, + config, + { operation: InstagramOperation.PUBLISH_SUB_COMMENT }, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/comment.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.interfaces.ts new file mode 100644 index 000000000..fd3b62d1d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.interfaces.ts @@ -0,0 +1,147 @@ +export interface KwaiRefreshTokenQuery { + app_id: string + app_secret: string + refresh_token: string + grant_type: 'refresh_token' +} + +export interface KwaiAccessTokenQuery { + app_id: string + app_secret: string + code: string + grant_type: 'authorization_code' +} + +export interface KwaiUserInfo { + name: string + // "M:男性,"F":女性,其他:未知。 + sex: 'M' | 'F' + fan: number + follow: number + head: string + bigHead: string + city: string +} + +export interface KwaiUserInfoQuery { + app_id: string + access_token: string +} + +// 快手 photo/start_upload +export interface KwaiStartUploadQuery { + app_id: string + access_token: string +} + +export interface KwaiStartUploadResponse { + upload_token: string + endpoint: string +} + +// 快手视频发布参数 +export interface KwaiVideoPubParams { + // 封面URL + coverUrl: string + // 视频URL + videoUrl: string + // 视频描述 + describe?: string + // 视频话题 + topics?: string[] +} + +export interface KwaiPublishVideoQuery { + upload_token: string + app_id: string + access_token: string +} + +export interface KwaiPublishVideoBody { + caption: string + cover: Buffer +} + +export interface KwaiChunkedUploadQuery { + fragment_id: number + upload_token: string +} + +export interface KwaiFinalizeUploadQuery { + fragment_count: number + upload_token: string +} + +export interface KwaiPhotoListQuery { + app_id: string + access_token: string + cursor?: string + count?: number +} + +export interface KwaiUserInfoResponse { + user_info: KwaiUserInfo +} + +export interface KwaiVideoInfo { + // 作品id + photo_id: string + // 作品标题 + caption: string + // 作品封面 + cover: string + // 作品播放链接 + play_url: string + // 作品创建时间 + create_time: number + // 作品点赞数 + like_count: number + // 作品评论数 + comment_count: number + // 作品观看数 + view_count: number + // 作品状态(是否还在处理中,不能观看) + pending: boolean +} + +export interface KwaiPublishVideoResponse { + video_info: KwaiVideoInfo +} + +export interface KwaiVideoListResponse { + // unknown fields, please refine + video_list: KwaiVideoInfo[] +} + +export interface KwaiApiCommonResponse { + result: number + error_msg?: string +} + +export interface KwaiOAuthCredentialsResponse { + result: number // 1 success + refresh_token: string + access_token: string + // access_token 的过期时间,单位为秒,有效期为48小时。 + expires_in: number + // refresh_token 的过期时间,单位为秒,有效期为180天。 + refresh_token_expires_in: number + open_id: string + scopes: string[] +} + +export interface KwaiVideoUploadResponse { + result: number +} + +export type KwaiApiResponse = T & KwaiApiCommonResponse + +// 发布视频的结果返回给上层 service 使用的封装(非开放平台直接返回字段) +export interface KwaiVideoPubResult { + success: boolean + worksId?: string + failMsg?: string +} + +export interface KwaiDeleteVideoResponse { +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.module.ts new file mode 100644 index 000000000..ed84b0119 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { KwaiApiService } from './kwaiApi.service' + +@Module({ + imports: [], + providers: [KwaiApiService], + exports: [KwaiApiService], +}) +export class KwaiApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.service.ts new file mode 100644 index 000000000..e58208fde --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.service.ts @@ -0,0 +1,313 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { chunkedDownloadFile, fileUrlToBase64, getRemoteFileSize } from '../../common' +import { config } from '../../config' +import { + KwaiAccessTokenQuery, + KwaiApiResponse, + KwaiChunkedUploadQuery, + KwaiDeleteVideoResponse, + KwaiFinalizeUploadQuery, + KwaiOAuthCredentialsResponse, + KwaiPhotoListQuery, + KwaiPublishVideoQuery, + KwaiPublishVideoResponse, + KwaiRefreshTokenQuery, + KwaiStartUploadQuery, + KwaiStartUploadResponse, + KwaiUserInfoQuery, + KwaiUserInfoResponse, + KwaiVideoListResponse, + KwaiVideoPubParams, + KwaiVideoPubResult, + KwaiVideoUploadResponse, +} from './kwaiApi.interfaces' + +@Injectable() +export class KwaiApiService { + private readonly appId: string + private readonly appSecret: string + private readonly redirectUri + private readonly kwaiAPIHost = 'https://open.kuaishou.com' + private readonly logger = new Logger(KwaiApiService.name) + + constructor() { + const cfg = config.kwai + this.appId = cfg.id + this.appSecret = cfg.secret + this.redirectUri = cfg.authBackHost + } + + private async request(url: string, config: AxiosRequestConfig = {}): Promise> { + this.logger.log(`Kwai API Request:${config.method || 'GET'} ${url} with params: ${JSON.stringify(config.params)}`) + try { + const response: AxiosResponse> = await axios(url, config) + const data = response.data + if (typeof data?.result === 'number' && data.result !== 1) { + this.logger.error(`${config.method || 'GET'} ${url} failed, response: ${JSON.stringify(data)}`) + throw new Error(data.error_msg || 'Kwai API business error') + } + return data + } + catch (error: unknown) { + this.logger.error(`Kwai API request failed (network): ${url}`, error) + throw new Error('Kwai API network error') + } + } + + /** + * 刷新token + * @param refresh_token + */ + async refreshToken(refresh_token: string) { + const params: KwaiRefreshTokenQuery = { + app_id: this.appId, + app_secret: this.appSecret, + refresh_token, + grant_type: 'refresh_token', + } + const url = `${this.kwaiAPIHost}/oauth2/refresh_token` + return await this.request(url, { params }) + } + + /** + * 获取登陆授权页 + * @param taskId + * @param type 'h5' | 'pc' + */ + getAuthPage(taskId: string, type: 'h5' | 'pc') { + const params = new URLSearchParams({ + app_id: this.appId, + scope: 'user_info,user_video_publish,user_video_info', + response_type: 'code', + ...(type === 'pc' ? { ua: 'pc' } : {}), + redirect_uri: `${this.redirectUri}/${taskId}`, + }) + const authParams = params.toString() + return `${this.kwaiAPIHost}/oauth2/authorize?${authParams}` + } + + /** + * 根据code获取快手账号的accessToken和refresh_token + * @param code + */ + async getLoginAccountToken(code: string) { + const params: KwaiAccessTokenQuery = { + app_id: this.appId, + app_secret: this.appSecret, + code, + grant_type: 'authorization_code', + } + const url = `${this.kwaiAPIHost}/oauth2/access_token` + return await this.request(url, { method: 'POST', params }) + } + + /** + * 获取快手账号信息 + * @param accessToken + */ + async getAccountInfo(accessToken: string) { + const params: KwaiUserInfoQuery = { + app_id: this.appId, + access_token: accessToken, + } + const url = `${this.kwaiAPIHost}/openapi/user_info` + const data = await this.request(url, { params }) + return data.user_info + } + + /** + * 发起上传 + */ + async startUpload(accessToken: string) { + this.logger.log('Initialize kwai video upload') + const params: KwaiStartUploadQuery = { + app_id: this.appId, + access_token: accessToken, + } + const url = `${this.kwaiAPIHost}/openapi/photo/start_upload` + const data = await this.request(url, { method: 'POST', params }) + this.logger.log(`Kwai video upload initialized: ${JSON.stringify(data)}`) + return data + } + + /** + * 上传视频 - 分片上传 + * @param upload_token 需通过 {@link startUpload} 方法获得 + * @param endpoint 需通过 {@link startUpload} 方法获得 + * @param fragment_id 分片id 从0开始 + * @param video 分片视频的 {@link ArrayBuffer} + */ + async fragmentUploadVideo( + upload_token: string, + fragment_id: number, + endpoint: string, + video: Buffer, + ) { + const params: KwaiChunkedUploadQuery = { fragment_id, upload_token } + const url = `http://${endpoint}/api/upload/fragment` + return await this.request(url, { + method: 'POST', + params, + headers: { 'Content-Type': 'application/octet-stream' }, + data: video, + }) + } + + /** + * 完成分片上传 + * @param upload_token upload_token 需通过 {@link startUpload} 方法获得} + * @param fragment_count 分片总数 + * @param endpoint 需通过 {@link startUpload} 方法获得 + */ + async completeFragmentUpload( + upload_token: string, + fragment_count: number, + endpoint: string, + ) { + const params: KwaiFinalizeUploadQuery = { fragment_count, upload_token } + const url = `http://${endpoint}/api/upload/complete` + return await this.request(url, { method: 'POST', params }) + } + + // 处理描述和话题,获取caption + getCaption(params: KwaiVideoPubParams) { + const { describe, topics } = params + let caption = '' + + if (describe) { + caption += `${describe} ` + } + + if (topics && topics.length !== 0) { + for (const topic of topics) { + caption += `#${topic} ` + } + } + + return caption.trim() + } + + /** + * 视频发布 发布视频接口为异步发布,该接口返回结果后,不代表视频已经同步发布到用户P页。如关心最终发布结果,需要自行判断。 + * @param accountToken + * @param pubParams 视频发布参数 + */ + async publishVideo( + accountToken: string, + pubParams: KwaiVideoPubParams, + ): Promise { + try { + const { coverUrl, videoUrl } = pubParams + this.logger.log(`start kwai video publish, videoUrl: ${videoUrl}, coverUrl: ${coverUrl}`) + const startUploadInfo = await this.startUpload(accountToken) + if (startUploadInfo.result !== 1) + throw new Error('发起上传失败') + + const contentLength = await getRemoteFileSize(videoUrl) + if (!contentLength) { + throw new Error('get video meta failed') + } + let chunkSize = 5 * 1024 * 1024 // 5MB + if (contentLength < chunkSize) { + chunkSize = contentLength + } + + const totalParts = Math.ceil(contentLength / chunkSize) + for (let partNumber = 0; partNumber < totalParts; partNumber++) { + const start = partNumber * chunkSize + const end = Math.min(start + chunkSize - 1, contentLength - 1) + const range: [number, number] = [start, end] + const videoBlob = await chunkedDownloadFile(videoUrl, range) + if (!videoBlob) { + throw new Error('download video chunk failed') + } + + const uploadResult = await this.fragmentUploadVideo( + startUploadInfo.upload_token, + partNumber, + startUploadInfo.endpoint, + videoBlob, + ) + this.logger.log(`chunked upload complete: ${JSON.stringify(uploadResult)}`) + } + + const finalizeUploadRes = await this.completeFragmentUpload( + startUploadInfo.upload_token, + totalParts, + startUploadInfo.endpoint, + ) + if (finalizeUploadRes.result !== 1) + throw new Error('finalize video upload failed') + + this.logger.log('Video upload complete, proceed to publish') + // 获取封面 + const coverBase64 = await fileUrlToBase64(coverUrl) + const buffer = Buffer.from(coverBase64, 'base64') + const coverBlob = new Blob([buffer], { type: 'image/jpeg' }) + this.logger.log('Cover image fetched and converted to Blob') + + const formData = new FormData() + formData.append('caption', this.getCaption(pubParams)) + formData.append('cover', coverBlob) + const params: KwaiPublishVideoQuery = { + upload_token: startUploadInfo.upload_token, + app_id: this.appId, + access_token: accountToken, + } + const publishUrl = `${this.kwaiAPIHost}/openapi/photo/publish` + const pubRes = await this.request(publishUrl, { + method: 'POST', + params, + data: formData, + }) + this.logger.log(`Kwai video publish response: ${JSON.stringify(pubRes)}`) + if (pubRes.result !== 1) + throw new Error('视频发布失败!') + return { + success: true, + worksId: pubRes.video_info.photo_id, + } + } + catch (e) { + this.logger.error(e) + return { + success: false, + failMsg: e.message || '视频发布失败', + } + } + } + + /** + * 获取快手视频列表 + * @param accessToken + * @param cursor + * @param count + */ + async fetchVideoList(accessToken: string, cursor?: string, count?: number) { + const params: KwaiPhotoListQuery = { + app_id: this.appId, + access_token: accessToken, + ...(cursor ? { cursor } : {}), + ...(count ? { count } : {}), + } + const url = `${this.kwaiAPIHost}/openapi/photo/list` + const data = await this.request(url, { params }) + if (!data.video_list) + throw new Error('快手获取快手视频列表失败') + return data.video_list + } + + async deleteVideo(accessToken: string, videoId: string) { + const body = { + app_id: this.appId, + access_token: accessToken, + photo_id: videoId, + } + const url = `${this.kwaiAPIHost}/openapi/photo/delete` + const data = await this.request(url, { method: 'POST', data: body }) + if (!data.result) + throw new Error('快手删除视频失败') + return data.result + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.spec.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/kwai/kwaiApi.spec.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/constants.ts new file mode 100644 index 000000000..740ec2cac --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/constants.ts @@ -0,0 +1,20 @@ +export const LinkedinOAuth2Config = { + pkce: false, + apiBaseUrl: 'https://api.linkedin.com/v2', + authURL: 'https://www.linkedin.com/oauth/v2/authorization', + // access token, expires in 60 days + accessTokenURL: 'https://www.linkedin.com/oauth/v2/accessToken', + // refresh token is expires in 365 days + refreshTokenURL: 'https://www.linkedin.com/oauth/v2/accessToken', + userProfileURL: + 'https://api.linkedin.com/v2/userinfo', + requestAccessTokenMethod: 'POST', + + defaultScopes: [ + 'openid', + 'profile', + 'email', + 'w_member_social', + ], + scopesSeparator: ' ', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.interface.ts new file mode 100644 index 000000000..84fdeebf3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.interface.ts @@ -0,0 +1,93 @@ +export const LifecycleState = 'PUBLISHED' + +export enum LinkedinShareCategory { + TEXT = 'TEXT', + ARTICLE = 'ARTICLE', + IMAGE = 'IMAGE', + VIDEO = 'VIDEO', +} + +export enum ShareMediaCategory { + NONE = 'NONE', + IMAGE = 'IMAGE', + ARTICLE = 'ARTICLE', +} + +export enum MemberNetworkVisibility { + PUBLIC = 'PUBLIC', + CONNECTIONS = 'CONNECTIONS', +} + +export enum UploadRecipe { + IMAGE = 'urn:li:digitalmediaRecipe:feedshare-image', + VIDEO = 'urn:li:digitalmediaRecipe:feedshare-video', +} + +export interface ShareCommentary { + text: string +} + +export interface mediaDescription { + text: string +} + +export interface mediaTitle { + text: string +} + +export interface ShareMedia { + status: string + description: mediaDescription + originalUrl?: string + title: mediaTitle + media: string +} + +export interface ShareContent { + shareCommentary: ShareCommentary + shareMediaCategory: ShareMediaCategory + media?: ShareMedia[] +} + +export interface ShareVisibility { + 'com.linkedin.ugc.MemberNetworkVisibility': MemberNetworkVisibility +} + +export interface LinkedInShareRequest { + author: string + lifecycleState: string + specificContent: { + 'com.linkedin.ugc.ShareContent': ShareContent + } + visibility: ShareVisibility +} + +export interface ServiceRelationship { + relationshipType: string + identifier: string +} + +export interface LinkedInUploadRequestData { + recipes: UploadRecipe[] + owner: string + serviceRelationships: ServiceRelationship[] +} + +export interface LinkedInUploadRequest { + registerUploadRequest: LinkedInUploadRequestData +} + +export interface LinkedInUploadResponseData { + uploadMechanism: { + 'com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest': { + headers: { [key: string]: string } + uploadUrl: string + } + } + mediaArtifact: string + asset: string +} + +export interface LinkedInUploadResponse { + value: LinkedInUploadResponseData +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.module.ts new file mode 100644 index 000000000..1bf6bd5a9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { LinkedinService } from './linkedin.service' + +@Module({ + imports: [], + providers: [LinkedinService], + exports: [LinkedinService], +}) +export class LinkedinModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.service.ts new file mode 100644 index 000000000..2b14b8041 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/linkedin/linkedin.service.ts @@ -0,0 +1,137 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' +import { OAuth2Credential } from '../../core/plat/meta/meta.interfaces' +import { LinkedinOAuth2Config } from './constants' +import { LinkedInShareRequest, LinkedInUploadRequest, LinkedInUploadResponse } from './linkedin.interface' + +@Injectable() +export class LinkedinService { + private readonly logger = new Logger(LinkedinService.name) + private readonly clientSecret: string = config.oauth.linkedin.clientSecret + private readonly clientId: string = config.oauth.linkedin.clientId + private readonly refreshAccessToken: string = LinkedinOAuth2Config.refreshTokenURL + private readonly apiBaseUrl: string = LinkedinOAuth2Config.apiBaseUrl + + async refreshOAuthCredential(refresh_token: string) { + const params: Record = { + grant_type: 'refresh_token', + refresh_token, + client_id: this.clientId, + client_secret: this.clientSecret, + } + + const refreshTokenReqParams = new URLSearchParams(params) + const tokenResponse: AxiosResponse + = await axios.post(this.refreshAccessToken, refreshTokenReqParams) + return tokenResponse.data + } + + async initMediaUpload(accessToken: string, req: LinkedInUploadRequest): Promise { + const url = `${this.apiBaseUrl}/assets?action=registerUpload` + const config: AxiosRequestConfig = { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + try { + const response: AxiosResponse = await axios.post(url, req, config) + const data = response.data + this.logger.log(`Init upload response: ${JSON.stringify(data)}`) + return data + } + catch (error) { + if (error.response) { + this.logger.error(`Error init upload: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + } + this.logger.error(`Error init upload: ${error.message}`) + throw new Error(`Error init upload: ${error.message}`) + } + } + + async streamUpload(accessToken: string, src: string, dest: string) { + const config: AxiosRequestConfig = { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-type': 'application/octet-stream', + }, + } + this.logger.log(`Upload URL: ${dest}`) + try { + const dlStream = await axios.get(src, { responseType: 'stream' }) + const response = await axios.post( + dest, + dlStream.data, + config, + ) + const data = response.data + this.logger.log(`Reel upload response: ${JSON.stringify(data)}`) + return data + } + catch (error) { + if (error.response) { + this.logger.error(`Error uploading reel: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + } + this.logger.error(`Error uploading reel: ${error.message}`) + throw new Error(`Error uploading reel: ${error.message}`) + } + } + + async createShare(accessToken: string, req: LinkedInShareRequest) { + const url = `${this.apiBaseUrl}/ugcPosts` + const config: AxiosRequestConfig = { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + try { + const response: AxiosResponse = await axios.post(url, req, config) + if (response.status !== 201) { + throw new Error(`Unexpected response status: ${response.status}`) + } + const shareId: string = response.headers['x-restli-id'] + if (!shareId) { + throw new Error('Missing x-restli-id header in response') + } + this.logger.log(`Create share response: ${JSON.stringify(response.data)}, shareId: ${shareId}`) + return shareId + } + catch (error) { + if (error.response) { + this.logger.error(`Error creating share: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + } + this.logger.error(`Error creating share: ${error.message}`) + throw new Error(`Error creating share: ${error.message}`) + } + } + + async deletePost(shareId: string, accessToken: string) { + const url = `${this.apiBaseUrl}/ugcPosts/${encodeURIComponent(shareId)}` + const config: AxiosRequestConfig = { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + try { + const response: AxiosResponse = await axios.delete(url, config) + if (response.status !== 204) { + throw new Error(`Unexpected response status: ${response.status}`) + } + this.logger.log(`Delete share response: ${JSON.stringify(response.data)}, shareId: ${shareId}`) + return shareId + } + catch (error) { + if (error.response) { + this.logger.error(`Error deleting share: ${error.response.status} - ${JSON.stringify(error.response.data)}`) + } + this.logger.error(`Error deleting share: ${error.message}`) + throw new Error(`Error deleting share: ${error.message}`) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/comment.ts new file mode 100644 index 000000000..a1376c3fa --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/comment.ts @@ -0,0 +1,71 @@ +export interface ComponentVerifyTicketData { + AppId: string + CreateTime: number + InfoType: 'component_verify_ticket' + ComponentVerifyTicket: string +} + +export interface TicketData { + AppId: string + Encrypt: string +} + +// 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 +export enum WxPublishStatus { + Success = 0, + Publishing = 1, + OriginalFail = 2, + RegularFail = 3, + PlatformAuditFail = 4, + SuccessAfterUserDeleteAllArticle = 5, + SuccessAfterSystemBanAllArticle = 6, +} + +export interface CallbackMsgData { + // 公众号的ghid + ToUserName: string + // 公众号群发助手的openid,为mphelper + FromUserName: string + // 创建时间的时间戳 + CreateTime: number + // 消息类型,此处为event + MsgType: string + // 事件信息,此处为PUBLISHJOBFINISH + Event: string + // 发布任务id + publish_id: string + // 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 + publish_status: WxPublishStatus + // 当发布状态为0时(即成功)时,返回图文的 article_id,可用于“客服消息”场景 + article_id: string + // 当发布状态为0时(即成功)时,返回文章数量 + count?: number + // 当发布状态为0时(即成功)时,返回文章对应的编号 + idx?: number + // 当发布状态为0时(即成功)时,返回图文的永久链接 + article_url?: string + // 当发布状态为2或4时,返回不通过的文章编号,第一篇为 1;其他发布状态则为空 + fail_idx?: number +} + +export interface WxPlat { + id: string + secret: string + token: string + encodingAESKey: string + authBackHost: string +} + +export interface WxPlatAuthorizerInfo { + authorizer_appid: string // 授权方 appid + authorizer_access_token: string // 接口调用令牌(在授权的公众号/小程序具备 API 权限时,才有此返回值) + expires_in: number // authorizer_access_token 的有效期(在授权的公众号/小程序具备API权限时,才有此返回值),单位:秒 + authorizer_refresh_token: string // 刷新令牌(在 + func_info: { + funcscope_category: { + id: number // 1; + } + }[] + errcode?: number + errmsg?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.module.ts new file mode 100644 index 000000000..daafe7fc2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { MyWxPlatApiService } from './myWxPlatApi.service' + +@Module({ + imports: [], + providers: [MyWxPlatApiService], + exports: [MyWxPlatApiService], +}) +export class MyWxPlatApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.service.ts new file mode 100644 index 000000000..c913218b0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/myWxPlat/myWxPlatApi.service.ts @@ -0,0 +1,188 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: MyWxPlat + */ +import { Injectable, Logger } from '@nestjs/common' +import axios from 'axios' +import { config } from '../../config' +import { WxPlatAuthorizerInfo } from './comment' + +@Injectable() +export class MyWxPlatApiService { + private id = '' + private secret = '' + private hostUrl = '' + private readonly logger = new Logger(MyWxPlatApiService.name) + constructor() { + const cfg = config.myWxPlat + this.id = cfg.id + this.secret = cfg.secret + this.hostUrl = cfg.hostUrl + } + + /** + * 获取授权链接 + * @param type + * @param stat 透传数据 + * @returns + */ + async getAuthPageUrl(type: 'h5' | 'pc', stat?: string) { + try { + const result = await axios.get<{ + data: string + code: string + messgage: string + }>( + `${this.hostUrl}/wxPlat/auth/url?type=${type}&key=${this.id}&stat=${stat}`, + { + headers: { + 'Content-Type': 'application/json', + 'secret': this.secret, + }, + }, + ) + if (result.data.code) + throw new Error(result.data.messgage) + + return result.data.data + } + catch (error) { + this.logger.error('------ Error wxPlat getAuthPageUrl: ------', error) + return null + } + } + + /** + * 使用获取授权 + * @param authorizationCode + * @returns + */ + async getQueryAuth(authorizationCode: string) { + try { + const result = await axios.get<{ + data: WxPlatAuthorizerInfo + code: string + messgage: string + } + + >( + `${this.hostUrl}/wxPlat/queryAuth/${authorizationCode}`, + { + headers: { + 'Content-Type': 'application/json', + 'secret': this.secret, + }, + }, + ) + if (result.data.code) + throw new Error(result.data.messgage) + + return result.data.data + } + catch (error) { + this.logger.error('------ Error wxPlat getQueryAuth: ------', error) + return null + } + } + + /** + * 使用授权码获取授权信息 + * @param authorizerAppid + * @returns + */ + async getAuthorizerInfo(authorizerAppid: string) { + try { + const result = await axios.get( + `${this.hostUrl}/wxPlat/authorizer/info/${authorizerAppid}`, + { + headers: { + 'Content-Type': 'application/json', + 'secret': this.secret, + }, + }, + ) + + if (result.data.code) + throw new Error(result.data.messgage) + + return result.data.data + } + catch (error) { + this.logger.error('------ Error wxPlat getAuthorizerInfo: ------', error) + return null + } + } + + /** + * 刷新用户的authorizer_access_token + * @param componentAccessToken + * @param appId 用的应用的appid + * @param authorizerRefreshToken 刷新token + * @returns + */ + async getAuthorizerAccessToken( + authorizerAppId: string, + authorizerRefreshToken: string, + ) { + try { + const result = await axios.get( + `${this.hostUrl}/wxPlat/authorizerAccessToken`, + { + headers: { + 'Content-Type': 'application/json', + 'secret': this.secret, + }, + params: { authorizerAppId, authorizerRefreshToken }, + }, + ) + + if (result.data.code) + throw new Error(result.data.messgage) + + return result.data.data + } + catch (error) { + this.logger.error( + '------ Error wxPlat getAuthorizerAccessToken: ------', + error, + ) + + return null + } + } + + /** + * 获取用户的授权信息 + * @param userId + * @param authorizationCode + * @returns + */ + async setUserAppAccessTokenInfo( + componentAccessToken: string, + authorizationCode: string, + ) { + try { + const result = await axios.post<{ + authorization_info: WxPlatAuthorizerInfo + errcode?: number + errmsg?: string + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_query_auth?access_token==${componentAccessToken}`, + { + component_appid: this.id, + authorization_code: authorizationCode, + }, + ) + if (result.data.errcode) + throw new Error(result.data.errcode + (result.data.errmsg || '')) + return result.data + } + catch (error) { + this.logger.log('Error setUserAppAccessTokenInfo :', error) + return null + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/common.ts new file mode 100644 index 000000000..1f3d6d7ac --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/common.ts @@ -0,0 +1,94 @@ +export interface PinterestApp { + id: string + secret: string + authBackHost: string + baseUrl: string +} + +export enum Country { + US = 'US', + CN = 'CN', + UK = 'UK', +} + +export enum Currency { + USD = 'USD', + UNK = 'UNK', +} + +export interface CreateBoardBody { + name: string // board名称; + accountId?: string +} + +export interface CreatePinBody { + link: string // 点击链接; + title?: string // 标题 + description?: string // 描述 + dominant_color?: string // RGB表示的颜色 主引脚颜色。十六进制数,例如“#6E7874”。 + alt_text?: string + board_id: string // 此 Pin 所属的板块。 + media_source: MediaSource + url?: string + accountId?: string + +} + +export interface IPinterestOptions { + boardId?: string +} + +interface CreatePinBodyItem { + url: string + title?: string // + description?: string + link?: string +} + +interface MediaSource { + source_type: SourceType + media_id?: string + url?: string + cover_image_url?: string + items?: CreatePinBodyItem[] +} + +export enum SourceType { + multiple_image_base64 = 'multiple_image_base64', + image_base64 = 'image_base64', + multiple_image_urls = 'multiple_image_urls', + image_url = 'image_url', + video_id = 'video_id', +} + +export enum ILoginStatus { + wait = 0, + success = 1, + expired = 2, +} + +export interface AuthInfo { + status: number + userId?: string + taskId?: string + accountId?: string + access_token?: string + expires_in?: number + refresh_token_expires_in?: number + userInfo?: object +} + +export interface UserInfo { + account_type: string + id: string + profile_image: string + website_url?: string + username: string + about?: string + business_name?: string + board_count?: number + pin_count?: number + follower_count?: number + following_count?: number + monthly_views?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.module.ts new file mode 100644 index 000000000..5564c5843 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.module.ts @@ -0,0 +1,15 @@ +/* + * @Author: white + * @Date: 2025-06-20 22:42:27 + * @LastEditors: white + * @Description: Pinterest + */ +import { Module } from '@nestjs/common' +import { PinterestApiService } from './pinterestApi.service' + +@Module({ + imports: [], + providers: [PinterestApiService], + exports: [PinterestApiService], +}) +export class PinterestApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.service.ts new file mode 100644 index 000000000..1c3238942 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/pinterest/pinterestApi.service.ts @@ -0,0 +1,283 @@ +/* + * @Author: white + * @Date: 2025-06-20 22:42:27 + * @LastEditors: white + * @Description: Pinterest + */ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosResponse } from 'axios' +import * as _ from 'lodash' +import qs from 'qs' +import { config } from '../../config' + +import { + CreateBoardBody, + CreatePinBody, + UserInfo, +} from './common' + +@Injectable() +export class PinterestApiService { + private readonly logger = new Logger(PinterestApiService.name) + appId: string + appSecret: string + baseUrl: string + redirect_uri: string + + constructor() { + const cfg = config.pinterest + this.appId = cfg.id + this.appSecret = cfg.secret + this.baseUrl = cfg.baseUrl + this.redirect_uri = cfg.authBackHost + } + + /** + * 获取用户信息 + * @param access_token + * @returns + */ + async getAccountInfo(access_token: string): Promise { + const uri = `${this.baseUrl}/v5/user_account?` + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${access_token}`, + } + const response: AxiosResponse = await axios + .get(uri, { headers }) + + return response.data + } + + /** + * 获取用户的授权Token + * @returns + */ + async authWebhook(code: string) { + const uri = `${this.baseUrl}/v5/oauth/token` + const body = qs.stringify({ + grant_type: 'authorization_code', + code, + redirect_uri: this.redirect_uri, + continuous_refresh: true, + }) + const pwd = `${this.appId}:${this.appSecret}` + const Authorization = `Basic ${Buffer.from(pwd).toString('base64')}` + const headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization, + } + + const result: any = await axios + .post(uri, body, { headers }) + .catch((err: any) => { + return this.logger.error( + '----- pinterest Error getAccessToken: ----', + err, + ) + }) + if (!result?.data || !result?.data.access_token) { + this.logger.log(result) + throw new Error(result) + } + + return result.data + } + + /** + * 创建board + * @param body + * @param headers + * @returns + */ + async createBoard(body: CreateBoardBody, headers: any) { + const uri = `${this.baseUrl}/v5/boards` + const result: any = await axios + .post(uri, body, { headers }) + .catch((err: any) => { + this.logger.error( + '----- pinterest Error createBoard: ----', + err.message, + ) + return { data: { message: '名称重复' } } + }) + return result.data + } + + /** + * 获取board列表信息 + * @returns + */ + async getBoardList(headers: any) { + const uri = `${this.baseUrl}/v5/boards` + const result: any = await axios.get(uri, { headers }).catch((err: any) => { + return this.logger.error( + '----- pinterest Error getBoardList: ----', + err, + ) + }) + const list = _.get(result, 'data.items') + const count = _.size(list) + return { list, count } + } + + /** + * 获取board信息 + * @param id board id + * @param headers + * @returns + */ + async getBoardById(id: string, headers: any) { + const uri = `${this.baseUrl}/v5/boards/${id}` + const result: any = await axios.get(uri, { headers }).catch((err: any) => { + return this.logger.error( + '----- pinterest Error getBoardById: ----', + err.message, + ) + }) + return result.data + } + + /** + * 删除board信息 + * @param id board id + * @param headers + * @returns + */ + async delBoardById(id: string, headers: any) { + const uri = `${this.baseUrl}/v5/boards/${id}` + const result: any = await axios + .delete(uri, { headers }) + .catch((err: any) => { + return this.logger.error( + '----- pinterest Error delBoardById: ----', + err.message, + ) + }) + return result.data + } + + /** + * 创建pin + * @param body + * @param headers + * @returns + */ + async createPin(body: CreatePinBody, headers: any) { + const uri = `${this.baseUrl}/v5/pins` + const result: any = await axios + .post(uri, body, { headers }) + .catch((err: any) => { + this.logger.log( + '----- pinterest Error createPin: ----', + err.code, + ) + return { data: { message: err } } + }) + return result.data + } + + /** + * 获取pin信息 + * @param id pin id + * @param headers + * @returns + */ + async getPinById(id: string, headers: any) { + const uri = `${this.baseUrl}/v5/pins/${id}` + const result: any = await axios.get(uri, { headers }).catch((err: any) => { + return this.logger.error( + '----- pinterest Error getPinById: ----', + err.message, + ) + }) + return result.data + } + + /** + * 获取pin列表信息 + * @returns + */ + async getPinList(headers: any) { + const uri = `${this.baseUrl}/v5/pins` + const result: any = await axios.get(uri, { headers }).catch((err: any) => { + return this.logger.error( + '----- pinterest Error getPinList: ----', + JSON.stringify(err), + ) + }) + const list = _.get(result, 'data.items') || [] + const count = _.size(list) + return { list, count } + } + + /** + * 删除pin + * @param id pin id + * @param headers + * @returns + */ + async delPinById(id: string, headers: any) { + const uri = `${this.baseUrl}/v5/pins/${id}` + const result: any = await axios + .delete(uri, { headers }) + .catch((err: any) => { + return this.logger.error( + '----- pinterest Error delPinById: ----', + err.message, + ) + }) + return result.data + } + + /** + * 获取视频 video_id + * @returns + */ + async getVideoId(headers: any) { + const uri = `${this.baseUrl}/v5/media` + const body = { media_type: 'video' } + const result: any = await axios.post(uri, body, { headers }) + .catch((err: any) => { + this.logger.log('----- pinterest Error getVideoId: ----', err.message) + return [err.message] + }) + return result + } + + /** + * 上传视频 + * @returns + */ + async uploadVideo(upload_url: string, formData: any) { + const config = { + method: 'post', + maxBodyLength: Infinity, + url: upload_url, + headers: { + ...formData.getHeaders(), + }, + data: formData, + } + const result: any = await axios.request(config) + .catch((err: any) => { + this.logger.log('----- pinterest Error uploadVideo: ----', err.message()) + return err + }) + return result + } + + /** + * 获取视频上传凭证 + * @returns + */ + async getUploadHeaders(headers: any) { + const uri = `${this.baseUrl}/v5/media` + const body = { media_type: 'video' } + const result: any = await axios.post(uri, body, { headers }) + .catch((err: any) => { + return this.logger.log('----- pinterest Error getUploadHeaders: ----', err.message) + }) + return result.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/constants.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/constants.ts new file mode 100644 index 000000000..6588178d7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/constants.ts @@ -0,0 +1,25 @@ +export const ThreadsOAuth2Config = { + pkce: false, + shortLived: true, + apiBaseUrl: 'https://graph.threads.net/v1.0/', + authURL: 'https://threads.net/oauth/authorize', + accessTokenURL: 'https://graph.threads.net/oauth/access_token', + pageAccountURL: '', + longLivedAccessTokenURL: 'https://graph.threads.net/access_token', + // refresh long-lived access token: https://developers.facebook.com/docs/threads/get-started/long-lived-access-tokens/ + refreshTokenURL: 'https://graph.threads.net/oauth/refresh_access_token', + userProfileURL: + 'https://graph.threads.net/v1.0/me?fields=id,username,name,threads_profile_picture_url,threads_biography', + requestAccessTokenMethod: 'POST', + defaultScopes: [ + 'threads_basic', + 'threads_content_publish', + // 'threads_manage_insights', + // 'threads_profile_discovery', + ], + longLivedGrantType: 'th_exchange_token', + longLivedParamsMap: { + access_token: 'access_token', + }, + scopesSeparator: ',', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.enum.ts new file mode 100644 index 000000000..a24bdec82 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.enum.ts @@ -0,0 +1,6 @@ +export enum ThreadsMediaType { + CAROUSEL = 'CAROUSEL', + REELS = 'IMAGE', + STORIES = 'TEXT', + VIDEO = 'VIDEO', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.interfaces.ts new file mode 100644 index 000000000..9887770dd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.interfaces.ts @@ -0,0 +1,131 @@ +export interface ThreadsPost { + reply_control?: string + allowlisted_country_codes?: string[] + alt_text?: string + auto_publish_text?: boolean + topic_tags?: string + location_id?: string +} + +export interface ThreadsContainerRequest { + is_carousel_item?: boolean + media_type?: 'IMAGE' | 'VIDEO' | 'CAROUSEL' | 'TEXT' + image_url?: string + video_url?: string + text?: string + children?: string[] + topic_tag?: string + reply_to_id?: string + location_id?: string +} + +export interface ThreadsPostResponse { + id: string +} + +export interface ThreadsObjectInfo { + id: string + status: string +} + +export interface ThreadsInsightsRequest { + metric: string + since?: number + until?: number +} + +export interface ThreadsInsightsMetricTotalValue { + value: number +} +export interface ThreadsInsightsMetricResult { + id: string + name: string + title: string + description: string + period: string + total_value?: ThreadsInsightsMetricTotalValue + values?: ThreadsInsightsMetricTotalValue[] +} + +export interface ThreadsPaginationReplies { + next: string + previous: string +} +export interface ThreadsInsightsResponse { + data: ThreadsInsightsMetricResult[] + paging: ThreadsPaginationReplies +} + +export interface publicProfileResponse { + follower_count: number + likes_count: number + quotes_count: number + replies_count: number + reposts_count: number + views_count: number +} + +export interface ThreadsPostItem { + id: string + media_product_type: string + media_type: string + media_url: string + permalink: string + thumbnail_url?: string + timestamp: string +} + +export interface ThreadsPostsResponse { + data: ThreadsPostItem[] + paging: ThreadsPaginationReplies +} + +export interface ThreadsPostsRequest { + fields: string[] + limit?: number + before?: string + after?: string +} + +export interface ThreadsObjectCommentsRequest { + fields: string + reverse: boolean + before?: string + after?: string +} + +export interface ThreadsComment { + id: string + text: string + timestamp: string + has_replies: boolean + username: string +} + +export interface ThreadsRepliesPagination { + cursors: { after: string, before: string } +} +export interface ThreadsObjectCommentsResponse { + data: ThreadsComment[] + paging: ThreadsRepliesPagination +} + +export interface ThreadsSearchLocationRequest { + query: string + fields?: string +} + +export interface ThreadsLocation { + id: string + name: string + address?: string + city?: string + country?: string + latitude?: number + longitude?: number + postal_code?: string +} + +export interface ThreadsSearchLocationResponse { + data: ThreadsLocation[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.module.ts new file mode 100644 index 000000000..2f84d4bf3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { ThreadsService } from './threads.service' + +@Module({ + imports: [], + providers: [ThreadsService], + exports: [ThreadsService], +}) +export class InstagramModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.service.ts new file mode 100644 index 000000000..5aa62f35d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/threads/threads.service.ts @@ -0,0 +1,237 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { MetaOAuthLongLivedCredential } from '../../core/plat/meta/meta.interfaces' +import { ThreadsOAuth2Config } from './constants' +import { + publicProfileResponse, + ThreadsContainerRequest, + ThreadsInsightsRequest, + ThreadsInsightsResponse, + ThreadsObjectCommentsRequest, + ThreadsObjectCommentsResponse, + ThreadsObjectInfo, + ThreadsPostResponse, + ThreadsPostsRequest, + ThreadsPostsResponse, + ThreadsSearchLocationRequest, + ThreadsSearchLocationResponse, +} from './threads.interfaces' + +@Injectable() +export class ThreadsService { + private readonly logger = new Logger(ThreadsService.name) + private readonly longLivedAccessTokenURL: string + = ThreadsOAuth2Config.longLivedAccessTokenURL + + private readonly apiBaseUrl: string = ThreadsOAuth2Config.apiBaseUrl + + private async request( + url: string, + config: AxiosRequestConfig = {}, + ): Promise { + this.logger.debug( + `Threads API Request: ${url} with config: ${JSON.stringify(config)}`, + ) + try { + const response: AxiosResponse = await axios(url, config) + return response.data + } + catch (error) { + if (error.response) { + this.logger.error( + `Threads API request failed: ${url}, status: ${error.response.status}, data: ${JSON.stringify(error.response.data)}`, + ) + throw new Error( + `Threads API request failed: ${error.response.data.error.message}`, + ) + } + this.logger.error(`Threads API request failed: ${url}`, error) + throw new Error(`Threads API request failed: ${error.message}`) + } + } + + async refreshOAuthCredential(refresh_token: string) { + const config: AxiosRequestConfig = { + method: 'GET', + params: { + grant_type: 'th_refresh_token', + access_token: refresh_token, + }, + } + return await this.request( + this.longLivedAccessTokenURL, + config, + ) + } + + async createItemContainer( + threadUserId: string, + accessToken: string, + req: ThreadsContainerRequest, + ): Promise { + const url = `${this.apiBaseUrl}${threadUserId}/threads` + const formData = new FormData() + Object.keys(req).forEach((key) => { + if (key !== 'children') { + formData.append(key, req[key]) + } + }) + if (req.children) { + req.children.forEach((child, index) => { + formData.append(`children[${index}]`, child) + }) + } + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + data: formData, + } + + return await this.request(url, config) + } + + async publishPost( + threadUserId: string, + accessToken: string, + creationId: string, + ): Promise { + const url = `${this.apiBaseUrl}${threadUserId}/threads_publish?creation_id=${creationId}` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } + + async getObjectInfo( + accessToken: string, + objectId: string, + fields?: string, + ): Promise { + const url = `${this.apiBaseUrl}/${objectId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + if (fields) { + config.params = { fields } + } + return await this.request(url, config) + } + + async getAccountInsights( + threadsUserId: string, + accessToken: string, + query: ThreadsInsightsRequest, + ): Promise { + const url = `${this.apiBaseUrl}${threadsUserId}/threads_insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request(url, config) + } + + async getPublicProfile( + accessToken: string, + username: string, + ): Promise { + const url = `${this.apiBaseUrl}public_profile` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { username }, + } + return await this.request(url, config) + } + + async getMediaInsights( + mediaId: string, + accessToken: string, + query: ThreadsInsightsRequest, + ): Promise { + const url = `${this.apiBaseUrl}${mediaId}/insights` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request(url, config) + } + + async getAccountAllPosts( + threadUserId: string, + accessToken: string, + query: ThreadsPostsRequest, + ): Promise { + const url = `${this.apiBaseUrl}${threadUserId}/threads` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request(url, config) + } + + async fetchObjectComments( + objectId: string, + accessToken: string, + query: ThreadsObjectCommentsRequest, + ): Promise { + const url = `${this.apiBaseUrl}${objectId}/replies` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: query, + } + return await this.request(url, config) + } + + async searchLocations( + accessToken: string, + query: ThreadsSearchLocationRequest, + ): Promise { + const url = `${this.apiBaseUrl}location_search` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + q: query.query, + }, + } + return await this.request(url, config) + } + + async deletePost( + postId: string, + accessToken: string, + ): Promise { + const url = `${this.apiBaseUrl}/${postId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + await this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.enum.ts new file mode 100644 index 000000000..cf681288d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.enum.ts @@ -0,0 +1,16 @@ +export enum TiktokPrivacyLevel { + PUBLIC = 'PUBLIC_TO_EVERYONE', + SELF_ONLY = 'SELF_ONLY', + FOLLOWER = 'FOLLOWER_OF_CREATOR', + FRIENDS = 'MUTUAL_FOLLOW_FRIENDS', +} + +export enum TiktokPostMode { + DIRECT_POST = 'DIRECT_POST', + MEDIA_UPLOAD = 'MEDIA_UPLOAD', +} + +export enum TiktokSourceType { + FILE_UPLOAD = 'FILE_UPLOAD', + PULL_FROM_URL = 'PULL_FROM_URL', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.interfaces.ts new file mode 100644 index 000000000..583e44c82 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.interfaces.ts @@ -0,0 +1,174 @@ +import { TiktokPrivacyLevel } from './tiktok.enum' + +// 基础发布信息 +interface TiktokBasePostInfo { + title?: string + description?: string + disable_comment?: boolean + disable_duet?: boolean + disable_stitch?: boolean + auto_add_music?: boolean + brand_content_toggle?: boolean + brand_organic_toggle?: boolean + video_cover_timestamp_ms?: number +} + +export interface TiktokPostOptions { + privacy_level: 'PUBLIC_TO_EVERYONE' | 'MUTUAL_FOLLOW_FRIENDS' | 'SELF_ONLY' | 'FOLLOWER_OF_CREATOR' + disable_duet?: boolean + disable_stitch?: boolean + disable_comment?: boolean + brand_organic_toggle?: boolean + brand_content_toggle?: boolean +} + +// 视频来源信息 +export type TiktokVideoSourceInfo + = | { + source: 'FILE_UPLOAD' + video_size: number + chunk_size: number + total_chunk_count: number + } + | { + source: 'PULL_FROM_URL' + video_url: string + } + +// 照片来源信息 +export interface TiktokPhotoSourceInfo { + source: 'PULL_FROM_URL' + photo_images: string[] + photo_cover_index: number +} + +// 视频发布请求 +export interface TiktokVideoPublishRequest { + post_info: TiktokBasePostInfo & { + privacy_level: TiktokPrivacyLevel + } + source_info: TiktokVideoSourceInfo +} + +// 照片发布请求 +export type TiktokPhotoPublishRequest + = | { + media_type: 'PHOTO' + post_mode: 'DIRECT_POST' + post_info: TiktokBasePostInfo & { + privacy_level: TiktokPrivacyLevel + } + source_info: TiktokPhotoSourceInfo + } + | { + media_type: 'PHOTO' + post_mode: 'MEDIA_UPLOAD' + post_info: TiktokBasePostInfo & { + privacy_level?: TiktokPrivacyLevel + } + source_info: TiktokPhotoSourceInfo + } + +// 发布响应 +export interface TiktokPublishResponse { + publish_id: string + upload_url?: string +} + +export interface TikTokUserInfo { + open_id: string + union_id: string + avatar_url: string + username: string + display_name: string + bio_description: string + follower_count?: number + following_count?: number + like_count?: number + video_count?: number +} +export interface TiktokCreatorInfo { + creator_avatar_url: string + creator_username: string + creator_nickname: string + privacy_level_options: TiktokPrivacyLevel[] + comment_disabled: boolean + duet_disabled: boolean + stitch_disabled: boolean + max_video_post_duration_sec: number +} + +// 导出通用的 PostInfo 类型供 Service 使用 +export type TiktokPostInfo = TiktokBasePostInfo & { + privacy_level: TiktokPrivacyLevel +} + +// OAuth 响应类型定义 +export interface TiktokOAuthResponse { + access_token: string + expires_in: number + open_id: string + refresh_token: string + refresh_expires_in: number + scope: string + token_type: string +} + +// 发布状态响应类型定义 +export interface TiktokPublishStatusResponse { + status: + | 'PROCESSING_DOWNLOAD' + | 'PROCESSING_UPLOAD' + | 'PROCESSING' + | 'PUBLISHED' + | 'FAILED' + fail_reason?: string +} + +// 撤销令牌响应类型 +export interface TiktokRevokeResponse { + message: string +} + +export interface TikTokUserInfoResponse { + data: { + user: TikTokUserInfo + } +} + +export interface TikTokVideo { + id: string + create_time: number + cover_image_url: string + share_url: string + video_description: string + duration: number + height: number + width: number + title: string + embed_html: string + embed_link: string + like_count: number + comment_count: number + share_count: number + view_count: number +} + +export interface TikTokListVideosResponse { + data: { + videos: TikTokVideo[] + has_more: boolean + cursor: string + } + error?: { + code: number + message: string + log_id: string + } +} + +export interface TikTokListVideosParams { + fields: string + cursor?: number + max_count?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.module.ts new file mode 100644 index 000000000..8151e18ff --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { TiktokService } from './tiktok.service' + +@Module({ + imports: [], + providers: [TiktokService], + exports: [TiktokService], +}) +export class TiktokModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.service.ts new file mode 100644 index 000000000..fca7135ae --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/tiktok/tiktok.service.ts @@ -0,0 +1,324 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-04-14 16:50:44 + * @LastEditors: nevin + * @Description: TikTok API Service + */ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' +import { + TiktokCreatorInfo, + TikTokListVideosParams, + TikTokListVideosResponse, + TiktokOAuthResponse, + TiktokPhotoPublishRequest, + TiktokPostInfo, + TiktokPublishResponse, + TiktokPublishStatusResponse, + TiktokRevokeResponse, + TikTokUserInfoResponse, + TiktokVideoPublishRequest, + TiktokVideoSourceInfo, +} from './tiktok.interfaces' + +@Injectable() +export class TiktokService { + private readonly logger = new Logger(TiktokService.name) + private readonly clientSecret: string + private readonly clientId: string + private readonly redirectUri: string + private readonly apiBaseUrl: string = 'https://open.tiktokapis.com/v2' + private readonly authUrl: string = 'https://www.tiktok.com/v2/auth/authorize' + + constructor() { + this.clientSecret = config.tiktok.clientSecret + this.clientId = config.tiktok.clientId + this.redirectUri = config.tiktok.redirectUri + } + + /** + * 通用 API 请求方法 + */ + private async apiRequest( + url: string, + options: AxiosRequestConfig = {}, + accessToken?: string, + ): Promise { + try { + const config: AxiosRequestConfig = { + ...options, + headers: { + ...options.headers, + }, + } + + if (accessToken) { + config.headers = { + ...config.headers, + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json; charset=UTF-8', + } + } + const response: AxiosResponse = await axios(url, config) + return response.data + } + catch (error) { + if (error.response) { + this.logger.error(`TikTok API request failed: ${url}, status: ${error.response.status}, data: ${JSON.stringify(error.response.data)}`) + const errMsg = error.response.error?.message || `TikTok API request failed: ${url}, status: ${error.response.status}` + throw new Error(errMsg) + } + this.logger.error(`TikTok API request failed: ${url}, error: ${error}`) + throw new Error(`TikTok API request failed: ${url}, error: ${error}`) + } + } + + /** + * OAuth 相关的请求方法 + */ + private async oauthRequest( + url: string, + data: Record, + ): Promise { + return this.apiRequest(url, { + method: 'POST', + data: new URLSearchParams(data), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + } + + /** + * 内容发布相关的请求方法 + */ + private async contentRequest( + url: string, + data: unknown, + accessToken: string, + ): Promise { + const response = await this.apiRequest<{ data: T }>( + url, + { + method: 'POST', + data, + }, + accessToken, + ) + return response.data + } + + /** + * 生成授权 URL + */ + generateAuthUrl(scopes: string[], state: string): string { + const params = new URLSearchParams({ + client_key: this.clientId, + scope: scopes.join(','), + response_type: 'code', + redirect_uri: this.redirectUri, + state, + }) + + return `${this.authUrl}/?${params.toString()}` + } + + /** + * 获取访问令牌 + */ + async getAccessToken(code: string): Promise { + return this.oauthRequest( + `${this.apiBaseUrl}/oauth/token/`, + { + client_key: this.clientId, + client_secret: this.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: this.redirectUri, + }, + ) + } + + /** + * 刷新访问令牌 + */ + async refreshAccessToken(refreshToken: string): Promise { + return this.oauthRequest( + `${this.apiBaseUrl}/oauth/token/`, + { + client_key: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }, + ) + } + + /** + * 撤销访问令牌 + */ + async revokeAccessToken(accessToken: string): Promise { + return this.oauthRequest( + `${this.apiBaseUrl}/oauth/revoke/`, + { + client_key: this.clientId, + client_secret: this.clientSecret, + token: accessToken, + }, + ) + } + + async getUserInfo(accessToken: string, fields = ''): Promise { + const userFields = fields || 'open_id,union_id,avatar_url,username,display_name,bio_description' + return this.apiRequest( + `${this.apiBaseUrl}/user/info/`, + { + method: 'GET', + params: { + fields: userFields, + }, + }, + accessToken, + ) + } + + /** + * 查询创作者信息 + */ + async getCreatorInfo(accessToken: string): Promise { + return this.contentRequest( + `${this.apiBaseUrl}/post/publish/creator_info/query/`, + {}, + accessToken, + ) + } + + /** + * 初始化视频发布 + */ + async initVideoPublish( + accessToken: string, + publishRequest: TiktokVideoPublishRequest, + ): Promise { + return this.contentRequest( + `${this.apiBaseUrl}/post/publish/inbox/video/init/`, + publishRequest, + accessToken, + ) + } + + /** + * 初始化照片发布 + */ + async initPhotoPublish( + accessToken: string, + publishRequest: TiktokPhotoPublishRequest, + ): Promise { + return this.contentRequest( + `${this.apiBaseUrl}/post/publish/content/init/`, + publishRequest, + accessToken, + ) + } + + /** + * 查询发布状态 + */ + async getPublishStatus( + accessToken: string, + publishId: string, + ): Promise { + return this.contentRequest( + `${this.apiBaseUrl}/post/publish/status/fetch/`, + { publish_id: publishId }, + accessToken, + ) + } + + /** + * 上传视频文件 + */ + async uploadVideoFile( + uploadUrl: string, + videoBuffer: Buffer, + contentType = 'video/mp4', + ): Promise { + const fileSize = videoBuffer.length + + await this.apiRequest(uploadUrl, { + method: 'PUT', + data: videoBuffer, + headers: { + 'Content-Type': contentType, + 'Content-Range': `bytes 0-${fileSize - 1}/${fileSize}`, + }, + }) + } + + /** + * 分片上传视频文件 + */ + async chunkedUploadVideoFile( + uploadUrl: string, + videoBuffer: Buffer, + range: [number, number], + fileSize: number, + contentType = 'video/mp4', + ): Promise { + await this.apiRequest(uploadUrl, { + method: 'PUT', + data: videoBuffer, + headers: { + 'Content-Type': contentType, + 'Content-Length': videoBuffer.length, + 'Content-Range': `bytes ${range[0]}-${range[1]}/${fileSize}`, + }, + }) + } + + /** + * 处理视频发布流程 + */ + async handleVideoPublish( + accessToken: string, + sourceInfo: TiktokVideoSourceInfo, + postInfo: TiktokPostInfo, + ): Promise { + const publishRequest: TiktokVideoPublishRequest = { + post_info: postInfo, + source_info: sourceInfo, + } + + const result = await this.initVideoPublish(accessToken, publishRequest) + + if (sourceInfo.source === 'FILE_UPLOAD') { + this.logger.log(`文件上传模式 - 文件大小: ${sourceInfo.video_size}`) + this.logger.log(`上传 URL: ${result.upload_url}`) + } + else { + this.logger.log(`URL 拉取模式 - 视频 URL: ${sourceInfo.video_url}`) + } + return result + } + + async getUserVideos( + accessToken: string, + query?: TikTokListVideosParams, + ): Promise { + const url = `${this.apiBaseUrl}/video/list/` + const config: AxiosRequestConfig = { + params: { fields: query?.fields }, + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + 'Authorization': `Bearer ${accessToken}`, + }, + } + const resp = await axios.post(url, { + cursor: query?.cursor, + max_count: query?.max_count, + }, config) + return resp.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.enum.ts new file mode 100644 index 000000000..ccf43c576 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.enum.ts @@ -0,0 +1,29 @@ +// see https://docs.x.com/x-api/media/media-upload-initialize +export enum XMediaCategory { + AMPLIFY_VIDEO = 'amplify_video', + TWEET_GIF = 'tweet_gif', + TWEET_IMAGE = 'tweet_image', + TWEET_VIDEO = 'tweet_video', + DM_GIF = 'dm_gif', + DM_IMAGE = 'dm_image', + DM_VIDEO = 'dm_video', + SUBTITLES = 'subtitles', +} + +export enum XMediaType { + VIDEO_MP4 = 'video/mp4', + VIDEO_WEBM = 'video/webm', + VIDEO_MP2T = 'video/mp2t', + VIDEO_QUICKTIME = 'video/quicktime', + TEXT_SRT = 'text/srt', + TEXT_VTT = 'text/vtt', + IMAGE_JPEG = 'image/jpeg', + IMAGE_GIF = 'image/gif', + IMAGE_BMP = 'image/bmp', + IMAGE_PNG = 'image/png', + IMAGE_WEBP = 'image/webp', + IMAGE_PJPEG = 'image/pjpeg', + IMAGE_TIFF = 'image/tiff', + MODEL_GLTF_BINARY = 'model/gltf-binary', + MODEL_USDZ_ZIP = 'model/vnd.usdz+zip', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.interfaces.ts new file mode 100644 index 000000000..3a2e260ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.interfaces.ts @@ -0,0 +1,267 @@ +import { XMediaCategory, XMediaType } from './twitter.enum' + +export interface TwitterOAuthCredential { + access_token: string + refresh_token: string + expires_in: number +} + +export interface TwitterUserInfo { + id: string + name: string + profile_image_url: string + username: string + verified: boolean + created_at: string + protected: boolean +} + +interface TwitterAPIError { + title: string + detail: string + type: string + status: number +} + +export interface TwitterUserInfoResponse { + data: TwitterUserInfo + errors?: TwitterAPIError[] +} + +export interface TwitterRevokeAccessResponse { + revoked: boolean + errors?: TwitterAPIError[] +} + +interface TwitterFollowingData { + following: boolean + pending_follow: boolean +} +export interface TwitterFollowingResponse { + data: TwitterFollowingData + errors?: TwitterAPIError[] +} + +export interface XMediaUploadInitRequest { + // command: 'INIT' | 'APPEND' | 'FINALIZE' + media_type: XMediaType + total_bytes: number + media_category: XMediaCategory + shared: boolean +} + +export interface XMediaUploadProcessingInfo { + state: 'succeeded' | 'in_progress' | 'pending' | 'failed' + progress_percent?: number + check_after_secs?: number +} + +export interface XMediaUploadResponseData { + id: string + media_key: string + expires_after_secs: number + size: number + processing_info: XMediaUploadProcessingInfo + expires_at?: string // ISO 8601 format + state?: string // e.g., "succeeded", "failed" +} + +export interface XMediaUploadResponse { + data: XMediaUploadResponseData + errors?: TwitterAPIError[] +} + +export interface XChunkedMediaUploadRequest { + media: Blob + media_id: string + segment_index: number +} + +export interface Geo { + place_id: string +} + +export interface PostMedia { + media_ids: string[] + tagged_users?: string[] +} + +enum PostPollReplySettings { + // following, mentionedUsers, subscribers, verified + FOLLOWING = 'following', + MENTIONED_USERS = 'mentionedUsers', + SUBSCRIBERS = 'subscribers', + VERIFIED = 'verified', +} + +export interface PostPoll { + options: string[] + duration_minutes: number + reply_settings?: PostPollReplySettings +} + +export interface postReply { + in_reply_to_tweet_id: string + exclude_reply_user_ids?: string[] +} +export interface XCreatePostRequest { + card_uri?: string + community_id?: string + direct_message_deep_link?: string + for_super_followers_only?: boolean + geo?: Geo + media?: PostMedia + nullcast?: boolean + poll?: PostPoll + quote_tweet_id?: string + reply?: postReply + reply_settings?: PostPollReplySettings + text?: string +} + +export interface XGetPostDetailRequest { + 'tweet.fields'?: string[] + 'expansions'?: string[] + 'media.fields'?: string[] + 'poll.fields'?: string[] + 'user.fields'?: string[] + 'place.fields'?: string[] +} + +export interface XPostAttachment { + 'attachments.media_keys'?: string[] + 'media_source_tweet_id'?: string + 'poll_ids'?: string[] +} + +export interface XPostPublicMetric { + bookmark_count: number + impression_count: number + like_count: number + reply_count: number + retweet_count: number + quote_count: number +} + +export interface XGetPostDetailResponseData { + id: string + text: string + username: string + author_id: string + attachments?: XPostAttachment + community_id?: string + conversation_id: string + created_at: string + display_text_range: number[] + edit_history_tweet_ids?: string[] + geo?: Geo + in_reply_to_user_id?: string + public_metrics: XPostPublicMetric +} + +export interface XGetPostDetailResponse { + data: XGetPostDetailResponseData + errors?: TwitterAPIError[] +} + +export interface RePostResponseData { + id: string + retweeted: boolean +} + +export interface LikePostResponseData { + liked: boolean +} + +export interface CreatePostResponseData { + id: string + text: string +} + +export interface DeletePostResponseData { + deleted: boolean +} + +export interface PublicMetrics { + retweet_count: number + reply_count: number + like_count: number + quote_count: number + impression_count: number + bookmark_count: number +} + +export interface PostAttachment { + media_keys?: string[] +} + +export interface XPostDetailResponseData { + public_metrics: PublicMetrics + id: string + text: string + author_id: string + created_at: string +} + +export interface XCreatePostResponse { + data: CreatePostResponseData + errors?: TwitterAPIError[] +} + +export interface XDeletePostResponse { + data: DeletePostResponseData + errors?: TwitterAPIError[] +} + +export interface XLikePostResponse { + data: LikePostResponseData + errors?: TwitterAPIError[] +} + +export interface XRePostResponse { + data: RePostResponseData + errors?: TwitterAPIError[] +} + +export interface XPostDetailResponse { + data: XPostDetailResponseData + errors?: TwitterAPIError[] +} + +export interface XUserTimelineRequest { + 'since_id'?: string + 'until_id'?: string + 'max_results'?: number + 'pagination_token'?: string + 'exclude'?: ('retweets' | 'replies')[] + 'start_time'?: string // ISO 8601 format + 'end_time'?: string // ISO 8601 format + 'expansions'?: string[] + 'tweet.fields'?: string[] + 'media.fields'?: string[] + 'poll.fields'?: string[] + 'user.fields'?: string[] + 'place.fields'?: string[] +} + +export interface XUserTimelineResponseMeta { + newest_id: string + oldest_id: string + result_count: number + next_token?: string + previous_token?: string +} +export interface XUserTimelineResponse { + data: XPostDetailResponseData[] + meta: XUserTimelineResponseMeta + errors?: TwitterAPIError[] +} + +export interface XDeleteTweetData { + deleted: boolean +} + +export interface XDeleteTweetResponse { + data: XDeleteTweetData + errors?: TwitterAPIError[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.module.ts new file mode 100644 index 000000000..034147639 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { TwitterService } from './twitter.service' + +@Module({ + imports: [], + providers: [TwitterService], + exports: [TwitterService], +}) +export class TwitterModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.service.ts new file mode 100644 index 000000000..f9692a8f2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/twitter/twitter.service.ts @@ -0,0 +1,413 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' +import { + TwitterFollowingResponse, + TwitterOAuthCredential, + TwitterRevokeAccessResponse, + TwitterUserInfo, + TwitterUserInfoResponse, + XChunkedMediaUploadRequest, + XCreatePostRequest, + XCreatePostResponse, + XDeletePostResponse, + XDeleteTweetResponse, + XLikePostResponse, + XMediaUploadInitRequest, + XMediaUploadResponse, + XPostDetailResponse, + XRePostResponse, + XUserTimelineRequest, + XUserTimelineResponse, +} from './twitter.interfaces' + +@Injectable() +export class TwitterService { + private readonly logger = new Logger(TwitterService.name) + private readonly clientSecret: string + private readonly clientId: string + private readonly redirectUri: string + private readonly apiBaseUrl: string = 'https://api.x.com/2' + private readonly authUrl: string = 'https://x.com/i/oauth2/authorize' + private readonly tokenSecret: string + private readonly oAuthRequestHeader: Record = {} + + constructor() { + this.clientSecret = config.twitter.clientSecret + this.clientId = config.twitter.clientId + this.redirectUri = config.twitter.redirectUri + this.tokenSecret = `${this.tokenSecret}` + this.oAuthRequestHeader = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${Buffer.from( + `${this.clientId}:${this.clientSecret}`, + ).toString('base64')}`, + } + } + + private async request( + url: string, + config: AxiosRequestConfig = {}, + ): Promise { + this.logger.debug( + `Twitter API Request: ${url} with config: ${JSON.stringify(config)}`, + ) + try { + const response: AxiosResponse = await axios(url, config) + return response.data + } + catch (error) { + if (error.response) { + this.logger.error( + `Twitter API request failed: ${url}, status: ${error.response.status}, data: ${JSON.stringify(error.response.data)}`, + ) + throw new Error( + `Twitter API request failed: ${error.response.data.error.message}`, + ) + } + this.logger.error(`Twitter API request failed: ${url}`, error) + throw new Error(`Twitter API request failed: ${error.message}`) + } + } + + generateAuthorizeURL( + scopes: string[], + state: string, + codeChallenge: string, + ): string { + const params = new URLSearchParams({ + client_id: this.clientId, + redirect_uri: this.redirectUri, + response_type: 'code', + scope: scopes.join(' '), + state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + const authorizeURL = new URL(this.authUrl) + authorizeURL.search = params.toString() + this.logger.debug(`Generated Twitter auth URL: ${authorizeURL.toString()}`) + return authorizeURL.toString() + } + + async getOAuthCredential( + code: string, + codeVerifier: string, + ): Promise { + const url = `${this.apiBaseUrl}/oauth2/token` + const params = new URLSearchParams({ + client_id: this.clientId, + grant_type: 'authorization_code', + code, + redirect_uri: this.redirectUri, + code_verifier: codeVerifier, + }) + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.oAuthRequestHeader, + data: params.toString(), + } + return await this.request(url, config) + } + + async refreshOAuthCredential( + refreshToken: string, + ): Promise { + const url = `${this.apiBaseUrl}/oauth2/token` + const params = new URLSearchParams({ + client_id: this.clientId, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }) + + const config: AxiosRequestConfig = { + method: 'POST', + headers: this.oAuthRequestHeader, + data: params.toString(), + } + return await this.request(url, config) + } + + async revokeOAuthCredential( + accessToken: string, + ): Promise { + const url = `${this.apiBaseUrl}/oauth2/revoke` + const params = new URLSearchParams({ + token: accessToken, + }) + const config: AxiosRequestConfig = { + headers: this.oAuthRequestHeader, + method: 'POST', + data: params.toString(), + } + return await this.request(url, config) + } + + async getUserInfo(accessToken: string): Promise { + const url = `${this.apiBaseUrl}/users/me` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + 'user.fields': + 'id,name,profile_image_url,username,verified,created_at,protected,public_metrics', + }, + } + return await this.request(url, config).then( + res => res.data, + ) + } + + async followUser( + accessToken: string, + xUserId: string, + ): Promise { + const url = `${this.apiBaseUrl}/users/${xUserId}/following` + const params = new URLSearchParams({ + target_user_id: xUserId, + }) + const config: AxiosRequestConfig = { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + method: 'POST', + data: params.toString(), + } + return await this.request(url, config) + } + + async initMediaUpload( + accessToken: string, + req: XMediaUploadInitRequest, + ): Promise { + const url = `${this.apiBaseUrl}/media/upload/initialize` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + data: req, + } + return await this.request(url, config) + } + + async chunkedMediaUploadRequest( + accessToken: string, + req: XChunkedMediaUploadRequest, + ): Promise { + const url = `${this.apiBaseUrl}/media/upload/${req.media_id}/append` + const formData = new FormData() + formData.append('media', new Blob([req.media])) + formData.append('segment_index', req.segment_index.toString()) + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'multipart/form-data', + }, + data: formData, + } + return await this.request(url, config) + } + + async finalizeMediaUpload( + accessToken: string, + mediaId: string, + ): Promise { + const url = `${this.apiBaseUrl}/media/upload/${mediaId}/finalize` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } + + async createPost( + accessToken: string, + tweet: XCreatePostRequest, + ): Promise { + const url = `${this.apiBaseUrl}/tweets` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + data: tweet, + } + return await this.request(url, config) + } + + async deletePost( + accessToken: string, + postId: string, + ): Promise { + const url = `${this.apiBaseUrl}/tweets/${postId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } + + async getUserPosts( + userId: string, + accessToken: string, + query: XUserTimelineRequest, + ): Promise { + const url = `${this.apiBaseUrl}/users/${userId}/tweets` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + ...query, + 'tweet.fields': + 'id,text,author_id,created_at,public_metrics,attachments', + }, + } + return await this.request(url, config) + } + + async getUserTimeline( + userId: string, + accessToken: string, + query: XUserTimelineRequest, + ): Promise { + const url = `${this.apiBaseUrl}/users/${userId}/timelines/reverse_chronological` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + ...query, + 'tweet.fields': + 'id,text,author_id,created_at,public_metrics,attachments', + }, + } + return await this.request(url, config) + } + + async getPostDetail( + accessToken: string, + postId: string, + ): Promise { + const url = `${this.apiBaseUrl}/tweets/${postId}` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + 'tweet.fields': 'created_at,public_metrics', + 'expansions': 'author_id', + 'user.fields': 'id,name,username,profile_image_url,verified', + }, + } + return await this.request(url, config) + } + + async getMediaStatus( + accessToken: string, + mediaId: string, + ): Promise { + const url = `${this.apiBaseUrl}/media/upload` + const config: AxiosRequestConfig = { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + media_id: mediaId, + }, + } + return await this.request(url, config) + } + + async repost( + userId: string, + accessToken: string, + tweetId: string, + ): Promise { + const url = `${this.apiBaseUrl}/2/${userId}/retweets` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + data: { tweet_id: tweetId }, + } + return await this.request(url, config) + } + + async unRepost( + userId: string, + accessToken: string, + tweetId: string, + ): Promise { + const url = `${this.apiBaseUrl}/2/${userId}/retweets/${tweetId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } + + async likePost( + userId: string, + accessToken: string, + tweetId: string, + ): Promise { + const url = `${this.apiBaseUrl}/2/${userId}/likes` + const config: AxiosRequestConfig = { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + data: { tweet_id: tweetId }, + } + return await this.request(url, config) + } + + async unlikePost( + userId: string, + accessToken: string, + tweetId: string, + ): Promise { + const url = `${this.apiBaseUrl}/2/${userId}/likes/${tweetId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } + + async deleteTweet( + accessToken: string, + tweetId: string, + ): Promise { + const url = `${this.apiBaseUrl}/2/tweets/${tweetId}` + const config: AxiosRequestConfig = { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + return await this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/common.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/common.ts new file mode 100644 index 000000000..1fce0124f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/common.ts @@ -0,0 +1,47 @@ +export enum MediaType { + image = 'image', + voice = 'voice', + video = 'video', + thumb = 'thumb', +} + +export interface WxGzhArticleNews { + article_type: 'news' + title: string // TITLE; + author?: string // AUTHOR; + digest?: string // DIGEST; + content: string // CONTENT; + content_source_url?: string // CONTENT_SOURCE_URL; + thumb_media_id: string // THUMB_MEDIA_ID; 永久素材的 + need_open_comment?: number // 0;是否打开评论,0不打开(默认),1打开 + only_fans_can_comment?: number // 0;否粉丝才可评论,0所有人可评论(默认),1粉丝才可评论 + pic_crop_235_1?: string // X1_Y1_X2_Y2; + pic_crop_1_1?: string // X1_Y1_X2_Y2; +} + +export interface WxGzhArticleNewsPic { + article_type: 'newspic' + title: string // TITLE; + content: string // CONTENT; + image_info: { + image_list: { + image_media_id: string // IMAGE_MEDIA_ID; + }[] + } + need_open_comment?: number // 0;是否打开评论,0不打开(默认),1打开 + only_fans_can_comment?: number // 0;否粉丝才可评论,0所有人可评论(默认),1粉丝才可评论 + cover_info?: { + crop_percent_list?: { + ratio: string // '1_1'; + x1: string // '0.166454'; + y1: string // '0'; + x2: string // '0.833545'; + y2: string // '1'; + }[] + } + product_info?: { + footer_product_info?: { + product_key?: string // PRODUCT_KEY; + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.module.ts new file mode 100644 index 000000000..39618cbf0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { WxGzhApiService } from './wxGzhApi.service' + +@Module({ + providers: [WxGzhApiService], + exports: [WxGzhApiService], +}) +export class WxGzhApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.service.ts new file mode 100644 index 000000000..a51e0cfb8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxGzh/wxGzhApi.service.ts @@ -0,0 +1,501 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: WxGzh + */ +import { Injectable, Logger } from '@nestjs/common' +import axios from 'axios' +import { WxGzhArticleNews, WxGzhArticleNewsPic } from './common' + +@Injectable() +export class WxGzhApiService { + /** + * 上传临时素材 + * @param accessToken + * @param media + * @param type + * @returns + */ + private readonly logger = new Logger(WxGzhApiService.name) + + async uploadTempMedia( + accessToken: string, + type: 'image' | 'voice' | 'video' | 'thumb', + file: Blob, + fileName: string, + ) { + try { + const formData = new FormData() + formData.append('media', file, fileName) + + const result = await axios.post<{ + type: 'image' | 'voice' | 'video' | 'thumb' // 'TYPE'; + media_id: string // 'MEDIA_ID'; + created_at: number // 123456789; + }>( + `https://api.weixin.qq.com/cgi-bin/media/upload?access_token=${accessToken}&type=${type}`, + formData, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return null + } + } + + /** + * 获取临时素材 + * @param accessToken + * @param mediaId + * @returns + */ + async getTempMedia(accessToken: string, mediaId: string) { + try { + const result = await axios.get<{ + video_url?: string // url; + errcode?: number // 40007; + errmsg?: string // 'invalid media_id'; + }>( + `https://api.weixin.qq.com/cgi-bin/media/get?access_token=${accessToken}&media_id=${mediaId}`, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return null + } + } + + /** + * 上传图文中的图片素材(不占用限制) + * @param accessToken + * @param file + * @returns + */ + async uploadImg(accessToken: string, file: Blob, fileName: string) { + try { + const formData = new FormData() + formData.append('media', file, fileName) + + const result = await axios.post<{ + url: string // url; + }>( + `https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`, + formData, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return null + } + } + + /** + * 上传永久素材 + * @param accessToken + * @param type + * @param file + * @returns + */ + async addMaterial( + accessToken: string, + type: 'image' | 'voice' | 'video' | 'thumb', + file: Blob, + fileName: string, + videoOptions?: { + title: string + introduction?: string + }, + ) { + try { + const formData = new FormData() + formData.append('media', file, fileName) + + // 如果是视频,则添加 description 参数 + if (type === 'video') + formData.append('description', JSON.stringify(videoOptions)) + + const result = await axios.post<{ + media_id: string // 'MEDIA_ID'; + url: string // 123456789; + errcode?: number // 40007; + errmsg?: string // 'invalid media_id'; + }>( + `https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=${type}`, + formData, + ) + + return result.data + } + catch (error) { + this.logger.log('wx Error during addMaterial:', error) + return null + } + } + + /** + * 获取永久素材 + * @param accessToken + * @param mediaId + * @returns + */ + async getMaterial(accessToken: string, mediaId: string) { + try { + const result = await axios.post< + | { + news_item: { + title: string // 'TITLE'; + thumb_media_id: string // 'THUMB_MEDIA_ID'; + show_cover_pic: string // 'SHOW_COVER_PIC'; + author: string // 'AUTHOR'; + digest: string // 'DIGEST'; + content: string // 'CONTENT'; + url: string // 'URL'; + content_source_url: string // 'CONTENT_SOURCE_URL'; + }[] + } + | { + title: string // TITLE; + description: string // DESCRIPTION; + down_url: string // DOWN_URL; + } + | { + errcode: number // 40007; + errmsg: string // 'invalid media_id'; + } + >( + `https://api.weixin.qq.com/cgi-bin/material/get_material?access_token=${accessToken}`, + { media_id: mediaId }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return null + } + } + + /** + * 新建草稿 + * @param accessToken + * @param data + * @returns + */ + async draftAdd( + accessToken: string, + data: WxGzhArticleNews | WxGzhArticleNewsPic, + ) { + try { + const result = await axios.post<{ + media_id: string // MEDIA_ID + errcode?: number // 40007; + errmsg?: string // 'invalid media_id'; + }>( + `https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, + { + articles: [data], + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return { + media_id: '', + errcode: 1, + errmsg: '新建草稿请求出错', + } + } + } + + /** + * 发布 + * @param accessToken + * @param mediaId + * @returns + */ + async freePublish(accessToken: string, mediaId: string) { + try { + const result = await axios.post<{ + errcode: number // 40007; + errmsg: string // 'invalid media_id'; + publish_id: string // '100000001'; + msg_data_id: string + }>( + `https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${accessToken}`, + { + media_id: mediaId, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during component_access_token:', error) + return { + publish_id: '', + msg_data_id: '', + errcode: 1, + errmsg: '发布请求失败', + } + } + } + + // -------- 留言管理 ----- + /** + * 回复评论 + * @param accessToken + * @param msgDataId 消息ID + * @param userCommentId 用户评论ID + * @param content 评论内容 + * @returns + */ + async listComment( + accessToken: string, + msgDataId: string, + begin: number, + count: number, // <=50 + ) { + if (count > 50) + count = 50 + try { + const result = await axios.post<{ + errmsg?: string // 'invalid media_id'; + errcode?: number // 40007; + comment: { + user_comment_id: string // USER_COMMENT_ID, + openid: string // openid,用户如果用非微信身份评论,不返回openid + create_time: string // CREATE_TIME, + content: string // CONTENT, + comment_type: number + reply: { + content: string // CONTENT, + create_time: string // CREATE_TIME + } + }[] + total: number + }>( + `https://api.weixin.qq.com/cgi-bin/comment/list?access_token=${accessToken}`, + { + msg_data_id: msgDataId, + index: 0, + begin, + count, + type: 0, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during getusersummary:', error) + return { + errcode: 1, + errmsg: '请求失败', + } + } + } + + /** + * 回复评论 + * @param accessToken + * @param msgDataId 消息ID + * @param userCommentId 用户评论ID + * @param content 评论内容 + * @returns + */ + async replycomment( + accessToken: string, + msgDataId: string, + userCommentId: string, + content: string, + ) { + try { + const result = await axios.post<{ + errmsg?: string // 'invalid media_id'; + errcode?: number // 40007; + }>( + `https://api.weixin.qq.com/cgi-bin/comment/reply/add?access_token=${accessToken}`, + { + msg_data_id: msgDataId, + index: 0, + user_comment_id: userCommentId, + content, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during getusersummary:', error) + return { + errcode: 1, + errmsg: '请求失败', + } + } + } + + // -------- datacube 统计数据 ----- + /** + * 获取用户增减数据 + * @param accessToken + * @param beginDate yyyy-MM-dd + * @param endDate yyyy-MM-dd 结束日期(最大跨度7天) + * @returns + */ + async getusersummary( + accessToken: string, + beginDate: string, + endDate: string, + ) { + try { + const result = await axios.post<{ + list: + { + ref_date: string // '2014-12-07'; + user_source: number // 0; + new_user: number // 0; + cancel_user: number // 0; + }[] + errmsg?: string // 'invalid media_id'; + errcode?: number // 40007; + }>( + `https://api.weixin.qq.com/datacube/getusersummary?access_token=${accessToken}`, + { + begin_date: beginDate, + end_date: endDate, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during getusersummary:', error) + return { + list: [], + errcode: 1, + errmsg: '请求失败', + } + } + } + + /** + * 获取累计用户数据 + * @param accessToken + * @param beginDate yyyy-MM-dd + * @param endDate yyyy-MM-dd 结束日期(最大跨度7天) + * @returns + */ + async getusercumulate( + accessToken: string, + beginDate: string, + endDate: string, + ) { + try { + const result = await axios.post<{ + list: + { + ref_date: string // '2014-12-07'; + cumulate_user: number // 0; + }[] + errmsg?: string // 'invalid media_id'; + errcode?: number // 40007; + }>( + `https://api.weixin.qq.com/datacube/getusercumulate?access_token=${accessToken}`, + { + begin_date: beginDate, + end_date: endDate, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during getusersummary:', error) + return { + list: [], + errcode: 1, + errmsg: '请求失败', + } + } + } + + /** + * 获取图文阅读概况数据 + * @param accessToken + * @param beginDate yyyy-MM-dd + * @param endDate yyyy-MM-dd 结束日期(最大值为昨日) + * @returns + */ + async getuserread( + accessToken: string, + beginDate: string, + endDate: string, + ) { + try { + const result = await axios.post<{ + list: + { + ref_date: string // 数据的日期,需在begin_date和end_date之间; + user_source: number // 用户从哪里进入来阅读该图文。99999999.全部;0:会话;1.好友;2.朋友圈;4.历史消息页;5.其他;6.看一看;7.搜一搜; + int_page_read_count: number // 图文页的阅读次数 + share_count: number // 分享的次数 + add_to_fav_count: number // 收藏的次数 + }[] + errmsg?: string // 'invalid media_id'; + errcode?: number // 40007; + }>( + `https://api.weixin.qq.com/datacube/getuserread?access_token=${accessToken}`, + { + begin_date: beginDate, + end_date: endDate, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during getusersummary:', error) + return { + list: [], + errcode: 1, + errmsg: '请求失败', + } + } + } + + async deleteArticle( + accessToken: string, + article_id: string, + ) { + try { + const result = await axios.post<{ + errcode?: number + errmsg?: string + }>( + `https://api.weixin.qq.com/cgi-bin/freepublish/delete?access_token=${accessToken}`, + { + article_id, + }, + ) + + return result.data + } + catch (error) { + this.logger.log('Error during deleteArticle:', error) + return { + errcode: 1, + errmsg: '请求失败', + } + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/comment.ts new file mode 100644 index 000000000..df90ae6db --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/comment.ts @@ -0,0 +1,73 @@ +export interface ComponentVerifyTicketData { + AppId: string + CreateTime: number + InfoType: 'component_verify_ticket' + ComponentVerifyTicket: string +} + +export interface TicketData { + AppId: string + Encrypt: string +} + +// 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 +export enum WxPublishStatus { + Success = 0, + Publishing = 1, + OriginalFail = 2, + RegularFail = 3, + PlatformAuditFail = 4, + SuccessAfterUserDeleteAllArticle = 5, + SuccessAfterSystemBanAllArticle = 6, +} + +export interface CallbackMsgData { + // 公众号的ghid + ToUserName: string + // 公众号群发助手的openid,为mphelper + FromUserName: string + // 创建时间的时间戳 + CreateTime: number + // 消息类型,此处为event + MsgType: string + // 事件信息,此处为PUBLISHJOBFINISH + Event: string + // 发布任务id + publish_id: string + // 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 + publish_status: WxPublishStatus + // 当发布状态为0时(即成功)时,返回图文的 article_id,可用于“客服消息”场景 + article_id: string + // 当发布状态为0时(即成功)时,返回文章数量 + count?: number + // 当发布状态为0时(即成功)时,返回文章对应的编号 + idx?: number + // 当发布状态为0时(即成功)时,返回图文的永久链接 + article_url?: string + // 当发布状态为2或4时,返回不通过的文章编号,第一篇为 1;其他发布状态则为空 + fail_idx?: number +} + +export interface WxPlat { + id: string + secret: string + token: string + encodingAESKey: string + authBackHost: string +} + +export interface WxPlatAuthorizerInfo { + authorizer_appid: string // 授权方 appid + authorizer_access_token: string // 接口调用令牌(在授权的公众号/小程序具备 API 权限时,才有此返回值) + expires_in: number // authorizer_access_token 的有效期(在授权的公众号/小程序具备API权限时,才有此返回值),单位:秒 + authorizer_refresh_token: string // 刷新令牌 + func_info: { + funcscope_category: { + id: number // 1; + } + }[] + errcode?: number + errmsg?: string +} + +// ----- 公众号 STR ----- diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.module.ts new file mode 100644 index 000000000..83818beff --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { WxPlatApiService } from './wxPlatApi.service' + +@Module({ + imports: [], + providers: [WxPlatApiService], + exports: [WxPlatApiService], +}) +export class WxPlatApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.service.ts new file mode 100644 index 000000000..78c481fdd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/wxPlat/wxPlatApi.service.ts @@ -0,0 +1,240 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: WxPlat + */ +import { Injectable, Logger } from '@nestjs/common' +import axios from 'axios' +import { config } from '../../config' +import { WxPlatAuthorizerInfo } from './comment' + +@Injectable() +export class WxPlatApiService { + private id = '' + private secret = '' + + private readonly logger = new Logger(WxPlatApiService.name) + constructor() { + const cfg = config.wxPlat + + this.id = cfg.id + this.secret = cfg.secret + } + + /** + * 设置component_access_token企业授权token + * componentVerifyTicket + * @returns + */ + async getComponentAccessToken(componentVerifyTicket: string): Promise<{ + component_access_token: string + expires_in: number // 有效期,单位:秒 + } | null> { + try { + const result = await axios.post<{ + component_access_token: string + expires_in: number // 有效期,单位:秒 + errcode?: number + errmsg?: string + }>('https://api.weixin.qq.com/cgi-bin/component/api_component_token', { + component_appid: this.id, + component_appsecret: this.secret, + component_verify_ticket: componentVerifyTicket, + }) + + if (result.data.errcode) + throw new Error(result.data.errcode + (result.data.errmsg || '')) + return result.data + } + catch (error) { + this.logger.error( + '------ Error wxPlat getComponentAccessToken: ------', + error, + ) + return null + } + } + + /** + * 获取预授权码 + * @returns + */ + async getPreAuthCode(componentAccessToken: string) { + try { + const result = await axios.post<{ + pre_auth_code: string + expires_in: number // 有效期 1800,单位:秒 + errcode?: number + errmsg?: string + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_create_preauthcode?component_access_token=${componentAccessToken}`, + { + component_appid: this.id, + }, + ) + if (result.data.errcode) + throw new Error(result.data.errcode + (result.data.errmsg || '')) + return result.data + } + catch (error) { + this.logger.error('------ Error wxPlat getPreAuthCode: ------', error) + return null + } + } + + /** + * 获取授权链接 + * @param preAuthCode + * @param redirectUri + * @param type + * @returns + */ + getAuthPageUrl(preAuthCode: string, redirectUri: string, type: 'h5' | 'pc') { + const url + = type === 'pc' + ? `https://mp.weixin.qq.com/cgi-bin/componentloginpage?component_appid=${this.id}&pre_auth_code=${preAuthCode}&redirect_uri=${redirectUri}&auth_type=1` + : `https://open.weixin.qq.com/wxaopen/safe/bindcomponent?action=bindcomponent&no_scan=1&component_appid=${this.id}&pre_auth_code=${preAuthCode}&redirect_uri=${redirectUri}&auth_type=1#wechat_redirect` + return url + } + + /** + * 使用授权码获取授权信息 + * @param componentAccessToken + * @param authorizationCode + * @returns + */ + async getQueryAuth(componentAccessToken: string, authorizationCode: string) { + try { + const result = await axios.post<{ + authorization_info: WxPlatAuthorizerInfo + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_query_auth?component_access_token=${componentAccessToken}`, + { + component_appid: this.id, + authorization_code: authorizationCode, + }, + ) + + return result.data.authorization_info + } + catch (error) { + this.logger.error('------ Error wxPlat getQueryAuth: ------', error) + return null + } + } + + /** + * 刷新用户的authorizer_access_token + * @param componentAccessToken + * @param appId 用的应用的appid + * @param authorizerRefreshToken 刷新token + * @returns + */ + async getAuthorizerAccessToken( + componentAccessToken: string, + authorizerAppId: string, + authorizerRefreshToken: string, + ) { + try { + const result = await axios.post<{ + authorizer_access_token: string + authorizer_refresh_token: string + expires_in: number // 有效期,单位:秒 2小时 + errcode?: number + errmsg?: string + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_authorizer_token?component_access_token=${componentAccessToken}`, + { + component_appid: this.id, + authorizer_appid: authorizerAppId, + authorizer_refresh_token: authorizerRefreshToken, + }, + ) + + if (result.data.errcode) + throw new Error(result.data.errcode + (result.data.errmsg || '')) + return result.data + } + catch (error) { + this.logger.error( + '------ Error wxPlat getAuthorizerAccessToken: ------', + error, + ) + + return null + } + } + + /** + * 获取用户的授权信息 + * @param userId + * @param authorizationCode + * @returns + */ + async setUserAppAccessTokenInfo( + componentAccessToken: string, + authorizationCode: string, + ) { + try { + const result = await axios.post<{ + authorization_info: WxPlatAuthorizerInfo + errcode?: number + errmsg?: string + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_query_auth?access_token==${componentAccessToken}`, + { + component_appid: this.id, + authorization_code: authorizationCode, + }, + ) + if (result.data.errcode) + throw new Error(result.data.errcode + (result.data.errmsg || '')) + return result.data + } + catch (error) { + this.logger.log('Error setUserAppAccessTokenInfo :', error) + return null + } + } + + /** + * 获取授权账号的详情 + * @param componentAccessToken + * @param authorizerAppid + * @returns + */ + async getAuthorizerInfo( + componentAccessToken: string, + authorizerAppid: string, + ) { + this.logger.debug('getAuthorizerInfo---args', { + componentAccessToken, + authorizerAppid, + }) + try { + const result = await axios.post<{ + authorizer_info: { + nick_name: string + user_name: string + head_img: string + errcode?: number + errmsg?: string + } + }>( + `https://api.weixin.qq.com/cgi-bin/component/api_get_authorizer_info?access_token=${componentAccessToken}`, + { + component_appid: this.id, + authorizer_appid: authorizerAppid, + }, + ) + + return result.data.authorizer_info + } + catch (error) { + this.logger.error('Error getAuthorizerInfo :', error) + return null + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/comment.ts new file mode 100644 index 000000000..3bd692149 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/comment.ts @@ -0,0 +1,28 @@ +export interface AccessToken { + access_token: string // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in?: number // 1630220614; + refresh_token?: string // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes?: string[] // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + token_type?: string + id_token?: string +} + +// 获取频道列表参数 +export interface GetChannelsListParams { + accountId: string + forHandle?: string + forUsername?: string + id?: string[] + mine?: boolean + maxResults?: number + pageToken?: string +} + +export interface GetVideosListParams { + accountId: string + chart?: string + id?: string + myRating?: boolean + maxResults?: number + pageToken?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtube.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtube.interface.ts new file mode 100644 index 000000000..e02753caf --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtube.interface.ts @@ -0,0 +1,109 @@ +export interface AccessToken { + access_token: string // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in?: number // 1630220614; + refresh_token?: string // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes?: string[] // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + token_type?: string + id_token?: string +} + +export interface ChannelsList { + handle?: string + userName?: string + id?: string + mine?: boolean +} + +export interface UpdateChannels { + channelId?: string + brandingSettings?: any + status?: any +} + +export interface VideoCategoriesList { + id?: string + regionCode?: string +} + +export interface VideosList { + id?: string + myRating?: boolean + maxResults?: number + pageToken?: string +} + +export interface CommentThreadsList { + accountId: string + allThreadsRelatedToChannelId?: string + id?: string + videoId?: string + maxResults?: number + pageToken?: string + order?: string + searchTerms?: string +} + +export interface CommentsList { + id?: string + parentId?: string + maxResults?: number + pageToken?: string +} + +export interface InsertComment { + snippet: { + parentId?: string + textOriginal?: string + } +} + +// YouTube视频上传初始化 +export interface YoutubeStartUpload { + result: number + upload_token: string + endpoint: string +} + +// YouTube视频发布参数 +export interface YoutubeVideoPubParams { + // 封面URL + coverUrl: string + // 视频URL + videoUrl: string + // 视频描述 + describe?: string + // 视频话题 + topics?: string[] +} + +// YouTube视频发布结果 +export interface YoutubeVideoPubResult { + // 是否成功 + success: boolean + // 失败消息 + failMsg?: string + // 作品ID + worksId?: string +} + +// YouTube视频发布响应 +export interface YoutubePublishVideoInfo { + // 作品id + photo_id: string + // 作品标题 + caption: string + // 作品封面 + cover: string + // 作品播放链接 + play_url: string + // 作品创建时间 + create_time: number + // 作品点赞数 + like_count: number + // 作品评论数 + comment_count: number + // 作品观看数 + view_count: number + // 作品状态(是否还在处理中,不能观看) + pending: boolean +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.module.ts new file mode 100644 index 000000000..e326d9185 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.module.ts @@ -0,0 +1,16 @@ +/* + * @Author: zhangwei + * @Date: 2025-05-15 20:59:55 + * @LastEditTime: 2025-05-15 20:59:55 + * @LastEditors: zhangwei + * @Description: youtube + */ +import { Module } from '@nestjs/common' +import { YoutubeApiService } from './youtubeApi.service' + +@Module({ + imports: [], + providers: [YoutubeApiService], + exports: [YoutubeApiService], +}) +export class YoutubeApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.service.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.service.ts new file mode 100644 index 000000000..877ab0b76 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/libs/youtube/youtubeApi.service.ts @@ -0,0 +1,339 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-04-14 16:50:44 + * @LastEditors: nevin + * @Description: Bilibili bilibili + */ +import { Injectable, Logger } from '@nestjs/common' +import axios from 'axios' +import { google } from 'googleapis' +import { config } from '../../config' +import { + ChannelsList, + CommentsList, + VideosList, +} from './youtube.interface' + +@Injectable() +export class YoutubeApiService { + private oauth2Client: any + private webClientSecret: string + private webClientId: string + private webRenderBaseUrl: string + private youtubeClient = google.youtube('v3') + private readonly logger = new Logger(YoutubeApiService.name) + + constructor() { + this.oauth2Client = new google.auth.OAuth2() + this.initYoutubeSecrets() + } + + private async initYoutubeSecrets() { + this.webClientSecret = config.youtube.secret + this.webClientId = config.youtube.id + this.webRenderBaseUrl = config.youtube.authBackHost + } + + /** + * 初始化 OAuth2 客户端并设置凭证 + * @param accessToken 传入的 access_token + */ + setCredentials(accessToken: string) { + this.oauth2Client.setCredentials({ + access_token: accessToken, + }) + } + + /** + * 初始化YouTube API客户端 + * @param accessToken 访问令牌 + * @returns YouTube API客户端 + */ + initializeYouTubeClient(accessToken: string): any { + const auth = new google.auth.OAuth2() + auth.setCredentials({ access_token: accessToken }) + return google.youtube({ version: 'v3', auth }) + } + + /** + * 刷新用户的YouTube访问令牌 + * @param refreshToken 刷新令牌 + * @returns 新的系统令牌 + */ + async refreshAccessToken(refreshToken: string) { + try { + const tokenUrl = 'https://oauth2.googleapis.com/token' + + // 请求体的参数 + const params = new URLSearchParams({ + client_id: this.webClientId, // 使用你的 client_id + client_secret: this.webClientSecret, // 使用你的 client_secret + refresh_token: refreshToken, // 提供刷新令牌 + grant_type: 'refresh_token', // 认证类型是刷新令牌 + }) + + // 发送 POST 请求到 Google token endpoint 来刷新 access token + const response = await axios.post(tokenUrl, params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }) + const accessTokenInfo = response.data + return accessTokenInfo + } + catch (err) { + // this.logger.error('Failed to refresh access token') + this.logger.error(err) + } + } + + /** + * 从谷歌获取用户信息 + * @param accessToken 访问令牌 + * @returns + */ + async getUserInfoFromGoogle(accessToken: string) { + try { + const responseGoogle = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + if (responseGoogle.data.code !== 0) + throw new Error(responseGoogle.data.message) + + return responseGoogle.data + } + catch (error) { + this.logger.error(error) + return null + } + } + + /** + * 获取频道列表 + * @param userId 用户ID + * @param handle 频道handle + * @param userName 用户名 + * @param id 频道ID + * @param mine 是否查询自己的频道 + * @returns 频道列表 + */ + async getChannelsList(requestParams: ChannelsList) { + try { + const response = await this.oauth2Client.channels.list(requestParams) + + const channels = response.data + this.logger.log(channels) + if (channels.length === 0) { + return [] + } + else { + return channels + } + } + catch (err) { + this.logger.log(err) + return err + } + } + + /** + * 更新频道 + * @param accessToken + * @param ChannelId 频道ID + * @param brandingSettings 品牌设置 + * @param status 状态 + * @returns 更新结果 + */ + async updateChannels(accessToken: string, ChannelId: string, brandingSettings: any, status: any) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Client.setCredentials(accessToken) + + // 构造请求体 + const requestBody: any = { + id: ChannelId, + } + + // 如果传递了 note,则添加到请求体 + if (brandingSettings !== undefined) { + requestBody.brandingSettings = brandingSettings + } + if (status !== undefined) { + requestBody.status = status + } + + // console.log(requestBody) + + // 调用 YouTube API 上传视频 + const response = await this.initializeYouTubeClient(accessToken).channelSections.update( + { + part: 'brandingSettings', + requestBody, + }, + ) + + // 返回上传的视频 ID + if (response.data) { + return response.data + } + else { + return 'Channels updated failed' + } + } + catch (error) { + return error + } + } + + /** + * 获取视频列表。 + * @param id 视频ID + * @param chart 图表类型 + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 视频列表 + */ + async getVideosList(requestParams: VideosList) { + try { + const response = await this.oauth2Client.videos.list(requestParams) + const videos = response.data + this.logger.log(videos) + if (videos.length === 0) { + // console.log('No videos found.'); + return [] + } + else { + // console.log(`This videos's ID is ${videos}.`); + return videos + } + } + catch (error) { + return error + } + } + + /** + * 获取评论列表。 + * @param id 评论ID + * @param parentId 父评论ID + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 评论列表 + */ + async getCommentsList(requestParams: CommentsList) { + try { + const response = await this.oauth2Client.comments.list(requestParams) + const comments = response.data + this.logger.log(comments) + return response + // if (comments.length === 0) { + // // console.log('No comments found.'); + // return [] + // } + // else { + // // console.log(`This comments's ID is ${comments}.`); + // return comments + // } + } + catch (error) { + return error + } + } + + async insertComment(requestParams: any) { + try { + const response = await this.oauth2Client.comments.insert(requestParams) + const comments = response.data + this.logger.log(comments) + return response + } + catch (error) { + return error + } + } + + // async publishVideo( + // accountToken: string, + // pubParams: YoutubeVideoPubParams, + // ): Promise { + // return new Promise(async (resolve) => { + // try { + // const { coverUrl, videoUrl } = pubParams + + // // 发起上传 + // const startUploadInfo = await this.startUpload(accountToken) + // if (startUploadInfo.result !== 1) + // throw new Error('发起上传失败') + + // // 获取封面 + // const coverBase64 + // = await this.fileToolsService.fileUrlToBase64(coverUrl) + + // const buffer = Buffer.from(coverBase64, 'base64') + // const coverBlob = new Blob([buffer], { type: 'image/jpeg' }) + + // Logger.log('封面获取成功:', coverBlob) + + // // 视频URL分片上传 + // void this.fileToolsService.streamDownloadAndUpload( + // videoUrl, + // async (upData: Buffer, partNumber: number) => { + // const res = await this.fragmentUploadVideo( + // startUploadInfo.upload_token, + // partNumber - 1, + // startUploadInfo.endpoint, + // upData, + // ) + // Logger.log('分片:', partNumber, res) + // if (res.result !== 1) + // throw new Error('分片上传失败') + // }, + // async (partCount) => { + // // 合并 + // const res = await this.completeFragmentUpload( + // startUploadInfo.upload_token, + // partCount - 1, + // startUploadInfo.endpoint, + // ) + // if (res.result !== 1) + // throw new Error('合并分片上传失败') + + // // 发布 + // const formData = new FormData() + // formData.append('caption', this.getCaption(pubParams)) + // formData.append('cover', coverBlob) + // const pubRes = await axios<{ + // video_info: KwaiPublishVideoInfo + // result: number + // }>({ + // url: `${this.kwaiHost}/openapi/photo/publish`, + // method: 'POST', + // params: { + // upload_token: startUploadInfo.upload_token, + // app_id: this.appId, + // access_token: accountToken, + // }, + // data: formData, + // }) + // if (pubRes.data.result !== 1) + // throw new Error('视频发布失败!') + + // resolve({ + // success: true, + // worksId: pubRes.data.video_info.photo_id, + // }) + // }, + // 4194304, + // ) + // } + // catch (e) { + // this.logger.error(e) + // resolve({ + // success: false, + // failMsg: e.message || '发布错误', + // }) + // } + // }) + // } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/src/main.ts b/project/aitoearn-monorepo/apps/aitoearn-channel/src/main.ts new file mode 100644 index 000000000..bde590edb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/src/main.ts @@ -0,0 +1,5 @@ +import { startApplication } from '@yikart/common' +import { AppModule } from './app.module' +import { config } from './config' + +startApplication(AppModule, config) diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.app.json b/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.app.json new file mode 100644 index 000000000..2703572e0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2023", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "commonjs", + "paths": { + }, + "types": ["node"], + "strict": true, + "strictBindCallApply": false, + "strictNullChecks": false, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": false, + "useUnknownInCatchVariables": false, + "importHelpers": true, + "outDir": "../../dist/out-tsc", + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.json b/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.json new file mode 100644 index 000000000..350bf177d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-channel/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + }, + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/config/config.js b/project/aitoearn-monorepo/apps/aitoearn-server/config/config.js new file mode 100644 index 000000000..b3aa9f61e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/config/config.js @@ -0,0 +1,986 @@ +const os = require('node:os') + +const { + REDIS_HOST, + REDIS_PORT, +} = process.env + +const { + MONGODB_HOST, + MONGODB_PORT, + MONGODB_USERNAME, + MONGODB_PASSWORD, +} = process.env + +const { + STATISTICS_DB_HOST, + STATISTICS_DB_PORT, + STATISTICS_DB_USERNAME, + STATISTICS_DB_PASSWORD, +} = process.env + +const { + CHANNEL_URL, + TASK_URL, + PAYMENT_URL, +} = process.env + +const { + JWT_SECRET, +} = process.env + +const { + APP_ENV, + APP_NAME, +} = process.env + +const { + FEISHU_WEBHOOK_URL, + FEISHU_WEBHOOK_SECRET, +} = process.env + +const { + MAIL_USER, + MAIL_PASS, +} = process.env + +const { + UCLOUD_PUBLIC_KEY, + UCLOUD_PRIVATE_KEY, + UCLOUD_PROJECT_ID, + UCLOUD_IMAGE_ID, +} = process.env + +const { + GITHUB_TOKEN, + GITHUB_REPO, +} = process.env + +const { + ONESIGNAL_APP_ID, + ONESIGNAL_REST_API_KEY, +} = process.env + +const { + KLING_ACCESS_KEY, + KLING_BASE_URL, + VOLCENGINE_API_KEY, + OPENAI_API_KEY, + OPENAI_BASE_URL, + DASHSCOPE_API_KEY, + DASHSCOPE_BASE_URL, + SORA2_API_KEY, + SORA2_BASE_URL, + MD2CARD_API_KEY, +} = process.env + +const { + ALI_GREEN_ACCESS_KEY_ID, + ALI_GREEN_ACCESS_KEY_SECRET, +} = process.env + +const { + INTERNAL_TOKEN, +} = process.env + +module.exports = { + port: 3002, + logger: { + console: { + enable: false, + level: 'debug', + }, + cloudWatch: { + enable: true, + region: 'ap-southeast-1', + group: `aitoearn-apps/${APP_ENV}/${APP_NAME}`, + stream: `${os.hostname()}`, + }, + feishu: { + enable: true, + url: FEISHU_WEBHOOK_URL, + secret: FEISHU_WEBHOOK_SECRET, + }, + }, + enableBadRequestDetails: true, + redis: { + nodes: [{ + host: REDIS_HOST, + port: Number(REDIS_PORT), + }], + options: { + redisOptions: { + db: 1, + tls: {}, + }, + }, + }, + mail: { + transport: { + host: 'smtp.feishu.cn', + port: 587, + secure: false, + auth: { + user: MAIL_USER, + pass: MAIL_PASS, + }, + }, + defaults: { + from: 'hello@aiearn.ai', + }, + }, + redlock: { + redis: { + nodes: [{ + host: REDIS_HOST, + port: Number(REDIS_PORT), + }], + options: { + redisOptions: { + db: 0, + tls: {}, + }, + }, + }, + }, + mongodb: { + uri: `mongodb://${MONGODB_USERNAME}:${encodeURIComponent(MONGODB_PASSWORD)}@${MONGODB_HOST}:${MONGODB_PORT}/?tls=true&tlsCAFile=global-bundle.pem&retryWrites=false`, + dbName: 'aitoearn', + }, + ucloud: { + publicKey: UCLOUD_PUBLIC_KEY, + privateKey: UCLOUD_PRIVATE_KEY, + imageId: UCLOUD_IMAGE_ID, + bundleId: 'ulh.c2m4s60b30t800cbECPro', + projectId: UCLOUD_PROJECT_ID, + }, + multilogin: { + launcherBaseUrl: 'https://launcher.mlx.yt:45001', + profileBaseUrl: 'https://api.multilogin.com', + timeout: 30000, + folderId: 'aab5e49e-0ec2-4254-b33f-44868dd373a2', + agent: { + gitUrl: 'https://github.com/yikart/browser-automation-worker.git', + gitBranch: 'main', + url: 'https://aitoearn.ai', + }, + }, + ansible: { + verbosity: 1, + timeout: 300, + forks: 5, + }, + github: { + repo: GITHUB_REPO, + token: GITHUB_TOKEN, + }, + oneSignal: { + appId: ONESIGNAL_APP_ID, + restApiKey: ONESIGNAL_REST_API_KEY, + }, + ai: { + fireflycard: { + apiUrl: 'https://fireflycard-api.302ai.cn/api/saveImg', + }, + md2card: { + baseUrl: 'https://md2card.cn', + apiKey: MD2CARD_API_KEY, + }, + kling: { + baseUrl: KLING_BASE_URL, + accessKey: KLING_ACCESS_KEY, + }, + volcengine: { + baseUrl: 'https://ark.cn-beijing.volces.com/', + apiKey: VOLCENGINE_API_KEY, + }, + openai: { + baseUrl: OPENAI_BASE_URL, + apiKey: OPENAI_API_KEY, + }, + dashscope: { + baseUrl: DASHSCOPE_BASE_URL, + apiKey: DASHSCOPE_API_KEY, + }, + sora2: { + baseUrl: SORA2_BASE_URL, + apiKey: SORA2_API_KEY, + }, + models: { + chat: [ + { + name: 'gpt-5', + description: 'GPT 5', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + pricing: { + prompt: '0.083', + completion: '0.666', + }, + }, + { + name: 'gpt-5-mini', + description: 'GPT 5 Mini', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + pricing: { + prompt: '0.016', + completion: '0.133', + }, + }, + { + name: 'gpt-5-nano', + description: 'GPT 5 Nano', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + pricing: { + prompt: '0.003', + completion: '0.026', + }, + }, + { + name: 'chatgpt-4o-latest', + description: 'ChatGPT 4o', + inputModalities: ['text', 'image'], + outputModalities: ['text'], + pricing: { + prompt: '0.333', + completion: '1', + }, + }, + { + name: 'gemini-2.5-pro', + description: 'Gemini 2.5 Pro', + inputModalities: ['text', 'image', 'audio', 'video'], + outputModalities: ['text'], + pricing: { + prompt: '0.083', + completion: '0.666', + }, + }, + { + name: 'gemini-2.5-flash', + description: 'Gemini 2.5 Flash', + inputModalities: ['text', 'image', 'audio', 'video'], + outputModalities: ['text'], + pricing: { + prompt: '0.020', + completion: '0.166', + }, + }, + { + name: 'gemini-2.5-flash-image', + description: 'Gemini 2.5 Flash Image (Nano Banana)', + inputModalities: ['text', 'image'], + outputModalities: ['image'], + pricing: { + price: '2', + }, + }, + { + name: 'qwen-vl-max', + description: 'Qwen-VL-Max', + inputModalities: ['text', 'image', 'video'], + outputModalities: ['text'], + pricing: { + prompt: '0.035', + completion: '0.04', + }, + }, + ], + image: { + generation: [ + { + name: 'gpt-image-1', + description: 'gpt-image-1', + sizes: ['1024x1024', '1536x1024', '1024x1536', 'auto'], + qualities: ['high', 'medium', 'low'], + styles: [], + pricing: '9', + }, + { + name: 'doubao-seedream-3-0-t2i-250415', + description: 'Doubao SeedDream 3.0', + sizes: ['1024x1024', '864x1152', '1152x864', '1280x720', '720x1280', '832x1248', '1248x832', '1512x648'], + qualities: [], + styles: [], + pricing: '3', + }, + { + name: 'doubao-seedream-4-0-250828', + description: 'Doubao SeedDream 4.0', + sizes: ['1K', '2K', '3K', '2048x2048', '2304x1728', '1728x2304', '2560x1440', '1440x2560', '2496x1664', '1664x2496', '3024x1296'], + qualities: [], + styles: [], + pricing: '1.5', + }, + { + name: 'flux-kontext-max', + description: ' FLUX.1 Kontext [max]', + sizes: ['1024x1024'], + qualities: [], + styles: [], + pricing: '5', + }, + { + name: 'flux-kontext-pro', + description: ' FLUX.1 Kontext [max]', + sizes: ['1024x1024'], + qualities: [], + styles: [], + pricing: '2.5', + }, + ], + edit: [ + { + name: 'gpt-image-1', + description: 'gpt-image-1', + sizes: ['1024x1024', '1536x1024', '1024x1536', 'auto'], + qualities: ['high', 'medium', 'low'], + styles: [], + pricing: '9', + maxInputImages: 16, + }, + { + name: 'doubao-seededit-3-0-i2i-250628', + description: 'Doubao SeedEdit 3.0', + sizes: ['adaptive'], + pricing: '3', + maxInputImages: 1, + }, + { + name: 'doubao-seedream-4-0-250828', + description: 'Doubao SeedDream 4.0', + sizes: ['1K', '2K', '3K', '2048x2048', '2304x1728', '1728x2304', '2560x1440', '1440x2560', '2496x1664', '1664x2496', '3024x1296'], + qualities: [], + styles: [], + pricing: '1.5', + maxInputImages: 10, + }, + { + name: 'flux-kontext-max', + description: ' FLUX.1 Kontext [max]', + sizes: ['1024x1024'], + qualities: [], + styles: [], + pricing: '5', + maxInputImages: 1, + }, + { + name: 'flux-kontext-pro', + description: ' FLUX.1 Kontext [max]', + sizes: ['1024x1024'], + qualities: [], + styles: [], + pricing: '2.5', + maxInputImages: 1, + }, + ], + }, + video: { + generation: [ + { + name: 'sora-2', + description: 'Sora2', + channel: 'sora2', + modes: ['text2video', 'multi-image2video'], + resolutions: ['large'], + durations: [10], + supportedParameters: ['image'], + defaults: { + resolution: 'large', + duration: 10, + }, + pricing: [ + { + resolution: 'large', + duration: 10, + price: 56, + }, + ], + }, + { + name: 'sora-2-pro', + description: 'Sora2 Pro', + channel: 'sora2', + modes: ['text2video', 'multi-image2video'], + resolutions: ['large'], + durations: [10], + supportedParameters: ['image'], + defaults: { + resolution: 'large', + duration: 10, + }, + pricing: [ + { + resolution: 'large', + duration: 10, + price: 280, + }, + ], + }, + { + name: 'doubao-seedance-1-0-pro-250528', + description: 'Doubao-Seedance-Pro', + channel: 'volcengine', + modes: ['text2video', 'image2video'], + resolutions: ['480p', '720p', '1080p'], + durations: [5, 10], + supportedParameters: ['image'], + defaults: { + resolution: '1080p', + duration: 5, + }, + pricing: [ + { + resolution: '1080p', + duration: 5, + price: 36.7, + }, + { + resolution: '1080p', + duration: 10, + price: 73.4, + }, + { + resolution: '480p', + duration: 5, + price: 7.2, + }, + { + resolution: '480p', + duration: 10, + price: 14.4, + }, + { + resolution: '720p', + duration: 5, + price: 16.4, + }, + { + resolution: '720p', + duration: 10, + price: 32.8, + }, + ], + }, + { + name: 'doubao-seedance-1-0-lite-i2v-250428', + description: 'Doubao-Seedance-Lite', + channel: 'volcengine', + modes: ['image2video'], + resolutions: ['480p', '720p', '1080p'], + durations: [5, 10], + supportedParameters: ['image', 'image_tail'], + defaults: { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + }, + pricing: [ + { + resolution: '1080p', + duration: 5, + price: 25, + }, + { + resolution: '1080p', + duration: 10, + price: 50, + }, + { + resolution: '480p', + duration: 5, + price: 5, + }, + { + resolution: '480p', + duration: 10, + price: 10, + }, + { + resolution: '720p', + duration: 5, + price: 11, + }, + { + resolution: '720p', + duration: 10, + price: 22, + }, + ], + }, + { + name: 'doubao-seedance-1-0-lite-t2v-250428', + description: 'Doubao-Seedance-Lite', + channel: 'volcengine', + modes: ['text2video'], + resolutions: ['480p', '720p', '1080p'], + durations: [5, 10], + supportedParameters: [], + defaults: { + resolution: '720p', + duration: 5, + aspectRatio: '16:9', + }, + pricing: [ + { + resolution: '1080p', + duration: 5, + price: 25, + }, + { + resolution: '1080p', + duration: 10, + price: 50, + }, + { + resolution: '480p', + duration: 5, + price: 5, + }, + { + resolution: '480p', + duration: 10, + price: 10, + }, + { + resolution: '720p', + duration: 5, + price: 11, + }, + { + resolution: '720p', + duration: 10, + price: 22, + }, + ], + }, + { + name: 'wanx2.1-t2v-turbo', + description: 'Wan2.1 Turbo', + channel: 'dashscope', + modes: ['text2video'], + resolutions: ['832*480', '480*832', '624*624', '1280*720', '720*1280', '960*960', '1088*832', '832*1088'], + durations: [5], + supportedParameters: [], + defaults: { + resolution: '1280*720', + duration: 5, + }, + pricing: [ + { + resolution: '832*480', + duration: 5, + price: 12, + }, + { + resolution: '480*832', + duration: 5, + price: 12, + }, + { + resolution: '624*624', + duration: 5, + price: 12, + }, + { + resolution: '1280*720', + duration: 5, + price: 12, + }, + { + resolution: '720*1280', + duration: 5, + price: 12, + }, + { + resolution: '960*960', + duration: 5, + price: 12, + }, + { + resolution: '1088*832', + duration: 5, + price: 12, + }, + { + resolution: '832*1088', + duration: 5, + price: 12, + }, + ], + }, + { + name: 'wanx2.1-i2v-turbo', + description: 'Wan2.1 Turbo', + channel: 'dashscope', + modes: ['image2video'], + resolutions: ['480P', '720P'], + durations: [3, 4, 5], + supportedParameters: ['image'], + defaults: { + resolution: '720P', + duration: 5, + }, + pricing: [ + { + resolution: '480P', + duration: 3, + price: 12, + }, + { + resolution: '720P', + duration: 3, + price: 12, + }, + { + resolution: '480P', + duration: 4, + price: 12, + }, + { + resolution: '720P', + duration: 4, + price: 12, + }, + { + resolution: '480P', + duration: 5, + price: 12, + }, + { + resolution: '720P', + duration: 5, + price: 12, + }, + ], + }, + { + name: 'wanx2.1-t2v-plus', + description: 'Wan2.1 Plus', + channel: 'dashscope', + modes: ['text2video'], + resolutions: ['1280*720', '720*1280', '960*960', '1088*832', '832*1088'], + durations: [5], + supportedParameters: [], + defaults: { + resolution: '1280*720', + duration: 5, + }, + pricing: [ + { + resolution: '1280*720', + duration: 5, + price: 35, + }, + { + resolution: '720*1280', + duration: 5, + price: 35, + }, + { + resolution: '960*960', + duration: 5, + price: 35, + }, + { + resolution: '1088*832', + duration: 5, + price: 35, + }, + { + resolution: '832*1088', + duration: 5, + price: 35, + }, + ], + }, + { + name: 'wanx2.1-i2v-plus', + description: 'Wan2.1 Plus', + channel: 'dashscope', + modes: ['image2video'], + resolutions: ['720P'], + durations: [5], + supportedParameters: ['image'], + defaults: { + resolution: '720P', + duration: 5, + }, + pricing: [ + { + resolution: '720P', + duration: 5, + price: 35, + }, + ], + }, + { + name: 'wanx2.1-kf2v-plus', + description: 'Wan2.1 Plus', + channel: 'dashscope', + modes: ['flf2video'], + resolutions: ['720P'], + durations: [5], + supportedParameters: ['image', 'image_tail'], + defaults: { + resolution: '720P', + duration: 5, + }, + pricing: [ + { + resolution: '720P', + duration: 5, + price: 35, + }, + ], + }, + { + name: 'wan2.2-t2v-plus', + description: 'Wan2.2 Plus', + channel: 'dashscope', + modes: ['text2video'], + resolutions: ['832*480', '480*832', '624*624', '1920*1080', '1080*1920', '1440*1440', '1632*1248', '1248*1632'], + durations: [5], + supportedParameters: [], + defaults: { + resolution: '1920*1080', + duration: 5, + }, + pricing: [ + { + resolution: '832*480', + duration: 5, + price: 7, + }, + { + resolution: '480*832', + duration: 5, + price: 7, + }, + { + resolution: '624*624', + duration: 5, + price: 7, + }, + { + resolution: '1920*1080', + duration: 5, + price: 35, + }, + { + resolution: '1080*1920', + duration: 5, + price: 35, + }, + { + resolution: '1440*1440', + duration: 5, + price: 35, + }, + { + resolution: '1632*1248', + duration: 5, + price: 35, + }, + { + resolution: '1248*1632', + duration: 5, + price: 35, + }, + ], + }, + { + name: 'wan2.2-i2v-plus', + description: 'Wan2.2 Plus', + channel: 'dashscope', + modes: ['image2video'], + resolutions: ['480P', '1080P'], + durations: [5], + supportedParameters: ['image'], + defaults: { + resolution: '1080P', + duration: 5, + }, + pricing: [ + { + resolution: '480P', + duration: 5, + price: 7, + }, + { + resolution: '1080P', + duration: 5, + price: 35, + }, + ], + }, + { + name: 'kling-v1-5', + description: 'Kling v1.5', + channel: 'kling', + modes: ['image2video', 'flf2video', 'lf2video'], + resolutions: [], + durations: [5, 10], + supportedParameters: ['image', 'image_tail'], + defaults: { + duration: 5, + mode: 'std', + }, + pricing: [ + { + duration: 5, + mode: 'std', + price: 20, + }, + { + duration: 10, + mode: 'std', + price: 40, + }, + { + duration: 5, + mode: 'pro', + price: 35, + }, + { + duration: 10, + mode: 'pro', + price: 70, + }, + ], + }, + { + name: 'kling-v1-6', + description: 'Kling v1.6', + channel: 'kling', + modes: ['text2video', 'image2video', 'flf2video', 'lf2video'], + resolutions: [], + durations: [5, 10], + supportedParameters: ['image', 'image_tail'], + defaults: { + duration: 5, + mode: 'std', + }, + pricing: [ + { + duration: 5, + mode: 'std', + price: 20, + }, + { + duration: 10, + mode: 'std', + price: 40, + }, + { + duration: 5, + mode: 'pro', + price: 35, + }, + { + duration: 10, + mode: 'pro', + price: 70, + }, + ], + }, + { + name: 'kling-v2-1', + description: 'Kling v2.1', + channel: 'kling', + modes: ['image2video', 'flf2video'], + resolutions: [], + durations: [5, 10], + supportedParameters: ['image', 'image_tail'], + defaults: { + duration: 5, + mode: 'std', + }, + pricing: [ + { + duration: 5, + mode: 'std', + price: 20, + }, + { + duration: 10, + mode: 'std', + price: 40, + }, + { + duration: 5, + mode: 'pro', + price: 35, + }, + { + duration: 10, + mode: 'pro', + price: 70, + }, + ], + }, + { + name: 'kling-v2-1-master', + description: 'Kling v2.1 Master', + channel: 'kling', + modes: ['text2video', 'image2video'], + resolutions: [], + durations: [5, 10], + supportedParameters: ['image'], + defaults: { + duration: 5, + mode: 'std', + }, + pricing: [ + { + duration: 5, + mode: 'std', + price: 100, + }, + { + duration: 10, + mode: 'std', + price: 200, + }, + ], + }, + ], + }, + }, + }, + aliGreen: { + accessKeyId: ALI_GREEN_ACCESS_KEY_ID, + accessKeySecret: ALI_GREEN_ACCESS_KEY_SECRET, + endpoint: `green-cip.cn-beijing.aliyuncs.com`, + }, + + awsS3: { + region: 'ap-southeast-1', + bucketName: 'aitoearn', + endpoint: 'https://aitoearn.s3.ap-southeast-1.amazonaws.com', + }, + mailBackHost: 'https://dev.aitoearn.ai', + channelApi: { + baseUrl: CHANNEL_URL, + }, + taskApi: { + baseUrl: TASK_URL, + }, + paymentApi: { + baseUrl: PAYMENT_URL, + }, + moreApi: { + platApiUri: 'https://platapi.yikart.cn', + xhsCreatorUri: 'http://39.106.41.190:7008', + }, + statisticsDb: { + uri: `mongodb://${STATISTICS_DB_USERNAME}:${encodeURIComponent(STATISTICS_DB_PASSWORD)}@${STATISTICS_DB_HOST}:${STATISTICS_DB_PORT}/?authSource=admin&directConnection=true`, + dbName: 'aitoearn_datas', + }, + auth: { + secret: JWT_SECRET, + internalToken: INTERNAL_TOKEN, + }, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/eslint.config.mjs b/project/aitoearn-monorepo/apps/aitoearn-server/eslint.config.mjs new file mode 100644 index 000000000..d95dfe5cf --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/eslint.config.mjs @@ -0,0 +1,4 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( +) diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/package.json b/project/aitoearn-monorepo/apps/aitoearn-server/package.json new file mode 100644 index 000000000..5b98ea095 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/package.json @@ -0,0 +1,63 @@ +{ + "name": "aitoearn-server", + "version": "0.0.1", + "private": true, + "dependencies": { + "@langchain/core": "^0.3.72", + "@langchain/openai": "^0.6.11", + "@nestjs/axios": "^4.0.1", + "@nestjs/bullmq": "^11.0.3", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.1", + "@nestjs/event-emitter": "^3.0.1", + "@nestjs/jwt": "^11.0.0", + "@nestjs/microservices": "^11.1.6", + "@nestjs/platform-express": "^11.1.6", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.2.0", + "@yikart/aitoearn-auth": "workspace:*", + "@yikart/aitoearn-queue": "workspace:*", + "@yikart/aitoearn-server-client": "workspace:*", + "@yikart/ali-green": "workspace:*", + "@yikart/ansible": "workspace:*", + "@yikart/aws-s3": "workspace:*", + "@yikart/common": "workspace:*", + "@yikart/mail": "workspace:*", + "@yikart/mongodb": "workspace:*", + "@yikart/multilogin": "workspace:*", + "@yikart/one-signal": "workspace:*", + "@yikart/redis": "workspace:*", + "@yikart/redlock": "workspace:*", + "@yikart/statistics-db": "workspace:*", + "@yikart/ucloud": "workspace:*", + "axios": "^1.12.2", + "bignumber.js": "^9.3.1", + "bullmq": "^5.58.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "data-urls": "^5.0.0", + "dayjs": "^1.11.14", + "fingerprint-generator": "^2.1.75", + "googleapis": "^150.0.1", + "header-generator": "^2.1.75", + "ioredis": "^5.7.0", + "js-yaml": "^4.1.0", + "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", + "mime-types": "^3.0.1", + "nats": "^2.29.3", + "openai": "^6.7.0", + "rxjs": "^7.8.0", + "uuid": "^11.1.0", + "xml2js": "^0.6.2", + "zod": "^4.0.17" + }, + "devDependencies": { + "@types/data-urls": "^3.0.4", + "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@types/jsonwebtoken": "^9.0.10", + "@types/lodash": "^4.17.20", + "@types/multer": "^1.4.13" + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/project.json b/project/aitoearn-monorepo/apps/aitoearn-server/project.json new file mode 100644 index 000000000..84b29b118 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/project.json @@ -0,0 +1,80 @@ +{ + "name": "aitoearn-server", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/aitoearn-server/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/aitoearn-server", + "tsConfig": "apps/aitoearn-server/tsconfig.app.json", + "packageJson": "apps/aitoearn-server/package.json", + "main": "apps/aitoearn-server/src/main.ts", + "assets": [ + "apps/aitoearn-server/*.md", + "apps/aitoearn-server/src/views/**/*", + "apps/aitoearn-server/src/public/**/*" + ], + "generatePackageJson": true, + "clean": true + } + }, + "prune-lockfile": { + "dependsOn": ["build"], + "cache": true, + "executor": "@nx/js:prune-lockfile", + "outputs": [ + "{workspaceRoot}/dist/apps/aitoearn-server/package.json", + "{workspaceRoot}/dist/apps/aitoearn-server/pnpm-lock.yaml" + ], + "options": { + "buildTarget": "build" + } + }, + "serve": { + "continuous": true, + "executor": "@nx/js:node", + "defaultConfiguration": "local", + "dependsOn": ["build"], + "options": { + "buildTarget": "aitoearn-server:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "local": { + "buildTarget": "aitoearn-server:build:development", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-server/config/local.config.js" + ] + }, + "dev": { + "buildTarget": "aitoearn-server:build:development", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-server/config/dev.config.js" + ] + }, + "prod": { + "buildTarget": "aitoearn-server:build:production", + "args": [ + "-c", + "{workspaceRoot}/apps/aitoearn-server/config/prod.config.js" + ] + } + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "fix": true + } + }, + "docker-context": {}, + "docker-build": {} + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.controller.ts new file mode 100644 index 000000000..64f75dd3e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.controller.ts @@ -0,0 +1,192 @@ +import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode } from '@yikart/common' +import { fileUtile } from '../util/file.util' +import { AccountService } from './account.service' +import { + AccountIdDto, + AccountListByIdsDto, + AccountListBySpaceIdsDto, + AccountStatisticsDto, + CreateAccountDto, + DeleteAccountsDto, + SortRankDto, + UpdateAccountDto, + UpdateAccountStatisticsDto, + UpdateAccountStatusDto, +} from './dto/account.dto' + +@ApiTags('账户') +@Controller('account') +export class AccountController { + constructor(private readonly accountService: AccountService) { } + + @ApiOperation({ summary: '创建账号' }) + @Post('login') + async createOrUpdateAccount( + @GetToken() token: TokenInfo, + @Body() body: CreateAccountDto, + ) { + const res = await this.accountService.addAccount(token.id, { + ...body, + }) + if (!res) { + throw new AppException(ResponseCode.AccountNotFound, 'Create account failed.') + } + res.avatar = fileUtile.buildUrl(res.avatar) + return res + } + + @ApiOperation({ summary: '更新账号' }) + @Post('update') + async updateAccountInfo( + @GetToken() token: TokenInfo, + @Body() body: UpdateAccountDto, + ) { + const account = await this.accountService.getAccountById(body.id) + if (!account || account.userId !== token.id) { + throw new AppException(ResponseCode.AccountNotFound, 'The account does not exist.') + } + const res = await this.accountService.updateAccountInfoById(body.id, { + userId: token.id, + ...body, + }) + return res + } + + @ApiOperation({ summary: '更新账号状态' }) + @Post('status') + async updateAccountStatus( + @GetToken() token: TokenInfo, + @Body() body: UpdateAccountStatusDto, + ) { + const account = await this.accountService.getAccountById(body.id) + if (!account || account.userId !== token.id) { + throw new AppException(ResponseCode.AccountNotFound, 'The account does not exist.') + } + return this.accountService.updateAccountStatus(body.id, body.status) + } + + @ApiOperation({ summary: '获取账号信息' }) + @Get(':id') + async getAccountInfo(@Param() param: AccountIdDto) { + return this.accountService.getAccountById(param.id) + } + + @ApiOperation({ summary: '获取用户所有账户' }) + @Get('list/all') + async getUserAccounts(@GetToken() token: TokenInfo) { + const res = await this.accountService.getUserAccounts(token.id) + res.forEach((item) => { + item.avatar = fileUtile.buildUrl(item.avatar) + }) + return res + } + + @ApiOperation({ summary: '删除多个账户' }) + @Post('deletes') + async deletes( + @GetToken() token: TokenInfo, + @Body() body: DeleteAccountsDto, + ) { + return this.accountService.deleteUserAccounts(body.ids, token.id) + } + + @ApiOperation({ summary: '获取账户列表' }) + @Post('list/ids') + async getAccountListByIds( + @GetToken() token: TokenInfo, + @Body() body: AccountListByIdsDto, + ) { + const res = await this.accountService.getAccountListByIdsOfUser(token.id, body.ids) + res.forEach((item) => { + item.avatar = fileUtile.buildUrl(item.avatar) + }) + return res + } + + @ApiOperation({ summary: '获取账户总数' }) + @Get('count') + async getAccountCount(@GetToken() token: TokenInfo) { + return this.accountService.getUserAccountCount(token.id) + } + + @ApiOperation({ summary: '获取账户统计' }) + @Get('statistics') + async getAccountStatistics( + @GetToken() token: TokenInfo, + @Query() query: AccountStatisticsDto, + ) { + return this.accountService.getAccountStatistics(token.id, query.type) + } + + @ApiOperation({ summary: '删除账户' }) + @Post('delete/:id') + async deleteAccount( + @GetToken() token: TokenInfo, + @Param() param: AccountIdDto, + ) { + const account = await this.accountService.getAccountById(param.id) + if (!account || account.userId !== token.id) { + throw new AppException(ResponseCode.AccountNotFound, 'The account does not exist.') + } + return this.accountService.deleteUserAccount(param.id, token.id) + } + + @ApiOperation({ summary: '更新账户统计信息' }) + @Post('statistics/update') + async updateAccountStatistics( + @GetToken() token: TokenInfo, + @Body() body: UpdateAccountStatisticsDto, + ) { + const account = await this.accountService.getAccountById(body.id) + if (!account || account.userId !== token.id) { + throw new AppException(ResponseCode.AccountNotFound, '账号不存在') + } + const { + id, + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + } = body + return this.accountService.updateAccountStatistics( + id, + { + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + }, + ) + } + + @ApiOperation({ summary: '获取账户列表(根据空间ids)' }) + @Post('list/spaceIds') + async getAccountListBySpaceIds( + @GetToken() token: TokenInfo, + @Query() query: AccountListBySpaceIdsDto, + ) { + const res = await this.accountService.listBySpaceIds(token.id, query.spaceIds) + res.forEach((item) => { + item.avatar = fileUtile.buildUrl(item.avatar) + }) + return res + } + + @ApiOperation({ summary: '更新排序' }) + @Put('sortRank') + async sortRank( + @GetToken() token: TokenInfo, + @Body() body: SortRankDto, + ) { + return this.accountService.sortRank(token.id, body.groupId, body.list) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.module.ts new file mode 100644 index 000000000..479077372 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from '@nestjs/common' +import { ChannelModule } from '../channel/channel.module' +import { CloudSpaceModule } from '../cloud/core/cloud-space' +import { FingerprintController } from '../fingerprint/fingerprint.controller' +import { FingerprintService } from '../fingerprint/fingerprint.service' +import { StatisticsModule } from '../statistics/statistics.module' +import { TaskModule } from '../task/task.module' +import { AccountController } from './account.controller' +import { AccountService } from './account.service' +import { AccountGroupController } from './accountGroup.controller' +import { AccountGroupService } from './accountGroup.service' + +@Global() +@Module({ + imports: [CloudSpaceModule, TaskModule, ChannelModule, StatisticsModule], + providers: [FingerprintService, AccountService, AccountGroupService], + controllers: [AccountController, AccountGroupController, FingerprintController], + exports: [AccountService, AccountGroupService], +}) +export class AccountModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.service.ts new file mode 100644 index 000000000..9f3875f0e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/account.service.ts @@ -0,0 +1,362 @@ +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common' +import { QueueService } from '@yikart/aitoearn-queue' +import { AccountType, TableDto } from '@yikart/common' +import { Account, AccountRepository, AccountStatus } from '@yikart/mongodb' +import { ChannelService } from '../channel/channel.service' +import { AccountPortraitReportData } from '../channel/common' +import { NewAccountCrawlerData } from '../statistics/common' +import { StatisticsService } from '../statistics/statistics.service' +import { AccountGroupService } from './accountGroup.service' +import { AccountFilterDto, CreateAccountDto } from './dto/account.dto' + +@Injectable() +export class AccountService { + logger = new Logger(AccountService.name) + + constructor( + private readonly accountRepository: AccountRepository, + @Inject(forwardRef(() => AccountGroupService)) + private readonly accountGroupService: AccountGroupService, + private readonly channelService: ChannelService, + private readonly statisticsService: StatisticsService, + private readonly queueService: QueueService, + ) { } + + /** + * 账户数据上报 + * @param data + */ + private async accountPortraitReport( + data: AccountPortraitReportData, + ) { + return await this.queueService.addTaskAccountPortraitReportJob(data) + } + + /** + * TODO: 新账号上报到数据爬取 + * @param data + */ + private async newChannelReport( + data: NewAccountCrawlerData, + ) { + return this.statisticsService.NewChannelReport( + data, + ) + } + + /** + * 将用户组下的账户切换到默认组 + * @param userId + * @param groupId + * @param defaultGroupId + */ + async switchToDefaultGroup( + userId: string, + groupId: string, + defaultGroupId: string, + ) { + return this.accountRepository.switchToDefaultGroup( + userId, + groupId, + defaultGroupId, + ) + } + + async addAccount(userId: string, data: CreateAccountDto): Promise { + this.logger.log(`Adding new account with data: ${JSON.stringify(data)}`) + if (!data.groupId) { + const defaultGroup = await this.accountGroupService.getDefaultGroup( + userId, + ) + data['groupId'] = defaultGroup.id + } + this.logger.log(`Using groupId: ${data.groupId} for new account`) + const info: Account | null = await this.accountRepository.addAccount({ + userId, + ...data, + }) + this.logger.log(`Account added: ${JSON.stringify(info)}`) + + try { + this.accountPortraitReport({ + accountId: info.id, + userId: info.userId, + type: info.type, + uid: info.uid, + avatar: info.avatar, + nickname: info.nickname, + status: AccountStatus.NORMAL, + totalFollowers: info.fansCount, + totalWorks: info.workCount, + totalViews: info.readCount, + totalLikes: info.likeCount, + totalCollects: info.collectCount, + }) + } + catch (error) { + this.logger.error(error) + } + + try { + this.newChannelReport({ + accountId: info.id, + userId: info.userId, + platform: info.type, + uid: info.uid, + avatar: info.avatar, + nickname: info.nickname, + }) + } + catch (error) { + this.logger.error(error) + } + + return info + } + + /** + * 更新账号信息 + * @param id + * @param account + * @returns + */ + async updateAccountInfoById( + id: string, + account: Partial, + ): Promise { + const oldInfo = await this.getAccountById(id) + if (!oldInfo) + return false + + const info = await this.accountRepository.updateAccountInfoById(id, account) + if (!info) + return false + + try { + this.accountPortraitReport({ + accountId: id, + userId: info.userId, + type: info.type, + uid: info.uid, + avatar: info.avatar, + nickname: info.nickname, + status: info.status, + totalFollowers: info.fansCount, + totalWorks: info.workCount, + totalViews: info.readCount, + totalLikes: info.likeCount, + totalCollects: info.collectCount, + }) + } + catch (error) { + this.logger.error(error) + } + return true + } + + /** + * 根据用户id获取账号 + */ + async getAccountById(id: string) { + return this.accountRepository.getAccountById(id) + } + + /** + * 获取所有账户 + * @param userId + * @returns + */ + async getUserAccounts(userId: string) { + const accounts = await this.accountRepository.getUserAccounts(userId) + + const accountMap: { [key: string]: Account } = {} + for (const account of accounts) { + accountMap[account.id] = account + } + try { + const channelAccounts = await this.channelService.getUserAccounts(userId) + for (const acc of channelAccounts) { + if (accountMap[acc._id]) { + accountMap[acc._id].status = acc.status + } + } + } + catch (error: any) { + this.logger.error(`get user accounts from channel error: ${error.message}`) + } + return accounts + } + + /** + * 获取所有账户 + * @param filterDto + * @param pageInfo + * @returns + */ + async getAccounts(filterDto: AccountFilterDto, pageInfo: TableDto) { + return this.accountRepository.getAccounts(filterDto, pageInfo) + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param userId + * @param ids + * @returns + */ + async getAccountListByIdsOfUser(userId: string, ids: string[]) { + return this.accountRepository.getAccountListByIdsOfUser(userId, ids) + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param ids + * @returns + */ + async getAccountListByIds(ids: string[]) { + return this.accountRepository.getAccountListByIds(ids) + } + + /** + * 获取账户的统计信息 + * @param userId + * @param type + * @returns + */ + async getAccountStatistics( + userId: string, + type?: AccountType, + ): Promise<{ + accountTotal: number + list: Account[] + fansCount?: number + readCount?: number + likeCount?: number + collectCount?: number + commentCount?: number + income?: number + }> { + return this.accountRepository.getAccountStatistics(userId, type) + } + + /** + * 获取用户的账户总数 + * @param userId + * @returns + */ + async getUserAccountCount(userId: string) { + return await this.accountRepository.getUserAccountCount(userId) + } + + /** + * 根据多个账户id查询账户信息 + * @param ids + * @returns + */ + async getAccountsByIds(ids: string[]) { + return await this.accountRepository.getAccountsByIds(ids) + } + + /** + * 删除 + * @param id + * @param userId + * @returns + */ + async deleteUserAccount(id: string, userId: string): Promise { + return await this.accountRepository.deleteUserAccount(id, userId) + } + + // 删除多个账户 + async deleteUserAccounts(ids: string[], userId: string) { + return await this.accountRepository.deleteUserAccounts(ids, userId) + } + + /** + * 更新渠道状态 + * @param id + * @param status + * @returns + */ + async updateAccountStatus(id: string, status: AccountStatus) { + const res = await this.accountRepository.updateAccountStatus(id, status) + this.channelService.updateChannelAccountStatus(id, status) + return res + } + + async updateAccountStatistics( + id: string, + data: { + fansCount?: number + readCount?: number + likeCount?: number + collectCount?: number + commentCount?: number + income?: number + workCount?: number + }, + ) { + const res = await this.accountRepository.updateAccountStatistics(id, data) + if (res) { + const accountInfo = await this.accountRepository.getAccountById(id) + if (!accountInfo) + return false + this.accountPortraitReport({ + type: AccountType.BILIBILI, + uid: accountInfo.uid, + totalFollowers: data.fansCount, + totalWorks: data.workCount, + totalViews: data.readCount, + totalLikes: data.likeCount, + totalCollects: data.collectCount, + }) + } + + return res + } + + /** + * 根据查询参数获取账号 + */ + async getAccountByParam(param: { [key: string]: string }) { + const res = await this.accountRepository.getAccountByParam(param) + return res + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param ids + * @returns + */ + async listByIds(ids: string[]) { + const res = await this.accountRepository.listByIds(ids) + return res + } + + /** + * 根据空间ID数组spaceIds获取账户列表数组 + * @param userId + * @param spaceIds + * @returns + */ + async listBySpaceIds(userId: string, spaceIds: string[]) { + const res = await this.accountRepository.listBySpaceIds(userId, spaceIds) + return res + } + + /** + * 根据type数组获取所有账户 + * @param types + * @param status + * @returns + */ + async getAccountsByTypes(types: string[], status?: number) { + const res = await this.accountRepository.getAccountsByTypes(types, status) + return res + } + + // 排序 + async sortRank(userId: string, groupId: string, list: { id: string, rank: number }[]) { + const res = await this.accountRepository.sortRank(userId, groupId, list) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.controller.ts new file mode 100644 index 000000000..f29bff65f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.controller.ts @@ -0,0 +1,78 @@ +import { Body, Controller, Get, Post, Put } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException } from '@yikart/common' +import * as _ from 'lodash' +import { CloudSpaceService } from '../cloud/core/cloud-space' +import { AccountGroupService } from './accountGroup.service' +import { CreateAccountGroupDto, DeleteAccountGroupDto, SortRankDto, UpdateAccountGroupDto } from './dto/accountGroup.dto' + +@ApiTags('账户组') +@Controller('accountGroup') +export class AccountGroupController { + constructor( + private readonly accountGroupService: AccountGroupService, + private readonly cloudSpaceService: CloudSpaceService, + ) {} + + @ApiOperation({ summary: '创建组' }) + @Post('create') + async create( + @GetToken() token: TokenInfo, + @Body() body: CreateAccountGroupDto, + ) { + return this.accountGroupService.createAccountGroup({ + userId: token.id, + ...body, + }) + } + + @ApiOperation({ summary: '更新组' }) + @Post('update') + async updateGroup( + @GetToken() token: TokenInfo, + @Body() body: UpdateAccountGroupDto, + ) { + const group = await this.accountGroupService.findOneById(body.id) + if (!group || group.userId !== token.id) { + throw new AppException(1000, 'Group does not exist') + } + + const res = await this.accountGroupService.updateAccountGroup(body.id, { + ...body, + userId: token.id, + }) + return res + } + + @ApiOperation({ summary: '删除账户组' }) + @Post('deletes') + async deletes( + @GetToken() token: TokenInfo, + @Body() body: DeleteAccountGroupDto, + ) { + return this.accountGroupService.deleteAccountGroup(body.ids, token.id) + } + + @ApiOperation({ summary: '获取用户所有账户组' }) + @Get('getList') + async getUserAccounts(@GetToken() token: TokenInfo) { + const res = await this.accountGroupService.getAccountGroup(token.id) + const cloudSpaces = await this.cloudSpaceService.listCloudSpacesByUserId({ + userId: token.id, + }) + const cloudSpacesMap = _.keyBy(cloudSpaces, 'accountGroupId') + + const sortedRes = _.sortBy(res, 'rank') + return sortedRes.map((ag: { id: string | number }) => Object.assign(ag, { cloudSpace: cloudSpacesMap[ag.id] })) + } + + @ApiOperation({ summary: '更新排序' }) + @Put('sortRank') + async sortRank( + @GetToken() token: TokenInfo, + @Body() body: SortRankDto, + ) { + return this.accountGroupService.sortRank(token.id, body.list) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.service.ts new file mode 100644 index 000000000..05737376f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/accountGroup.service.ts @@ -0,0 +1,86 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common' +import { AccountGroup, AccountGroupRepository } from '@yikart/mongodb' +import { FingerprintService } from '../fingerprint/fingerprint.service' +import { AccountService } from './account.service' + +@Injectable() +export class AccountGroupService { + constructor( + @Inject(forwardRef(() => AccountService)) + private readonly accountService: AccountService, + private readonly fingerprintService: FingerprintService, + private readonly accountGroupRepository: AccountGroupRepository, + ) { } + + // 获取默认用户组, 没有则创建 + async findOneById(id: string) { + const data = await this.accountGroupRepository.findOneById(id) + return data + } + + // 获取默认用户组, 没有则创建 + async getDefaultGroup(userId: string): Promise { + const data = await this.accountGroupRepository.getDefaultGroup(userId) + return data + } + + /** + * 添加组 + * @param accountGroup + */ + async createAccountGroup( + accountGroup: Partial, + ): Promise { + if (!accountGroup.browserConfig) { + accountGroup.browserConfig = await this.fingerprintService.generateFingerprint() + } + const data = await this.accountGroupRepository.createAccountGroup(accountGroup) + return data + } + + async updateAccountGroup( + id: string, + accountGroup: Partial, + ): Promise { + const data = await this.accountGroupRepository.updateAccountGroup(id, accountGroup) + return data + } + + /** + * 删除多个组 + * @param ids + * @param userId + */ + async deleteAccountGroup(ids: string[], userId: string): Promise { + const accountGroupList = await this.accountGroupRepository.getAccountGorupListByIds(ids, userId) + // 默认用户组 + const defaultGroup = await this.getDefaultGroup(userId) + // 将删除的组下面的账户切换为默认组 + for (const group of accountGroupList) { + await this.accountService.switchToDefaultGroup( + userId, + group.id, + defaultGroup.id, + ) + } + + const data = await this.accountGroupRepository.deleteAccountGroup(ids, userId) + return data + } + + /** + * 获取所有组 + * @param userId + * @returns + */ + async getAccountGroup(userId: string): Promise { + const accountGroupList: AccountGroup[] = await this.accountGroupRepository.getAccountGroup(userId) + return accountGroupList + } + + // 排序 + async sortRank(userId: string, list: { id: string, rank: number }[]): Promise { + const success = await this.accountGroupRepository.sortRank(userId, list) + return success + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/comment.ts new file mode 100644 index 000000000..4bf1211ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/comment.ts @@ -0,0 +1,12 @@ +export interface AccountGroup { + id: string + userId: string + isDefault: boolean + ip?: string + location?: string + proxyIp?: string + name: string + rank: number + createAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/account.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/account.dto.ts new file mode 100644 index 000000000..7b279d0b9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/account.dto.ts @@ -0,0 +1,187 @@ +import { ApiProperty } from '@nestjs/swagger' +import { AccountType, createZodDto } from '@yikart/common' +import { AccountStatus } from '@yikart/mongodb' +import { Expose } from 'class-transformer' +import { + IsArray, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator' +import dayjs from 'dayjs' +import { z } from 'zod' + +const CreateAccountSchema = z.object({ + refresh_token: z.string().min(1).optional(), + access_token: z.string().min(1).optional(), + type: z.enum(AccountType), + loginCookie: z.string().min(1).optional(), + loginTime: z.string().transform((val) => { + if (!val) + return undefined + const parsed = dayjs(val) + if (!parsed.isValid()) { + throw new Error(`Invalid date format: ${val}. Expected ISO 8601 format (e.g., "2025-09-04T10:30:00.000Z")`) + } + return parsed.toDate() + }).optional(), + uid: z.string().min(1), + account: z.string().min(1), + avatar: z.string().optional(), + nickname: z.string().min(1), + fansCount: z.number().optional(), + readCount: z.number().optional(), + likeCount: z.number().optional(), + collectCount: z.number().optional(), + forwardCount: z.number().optional(), + commentCount: z.number().optional(), + lastStatsTime: z.string().transform((val) => { + if (!val) + return undefined + const parsed = dayjs(val) + if (!parsed.isValid()) { + throw new Error(`Invalid date format: ${val}. Expected ISO 8601 format (e.g., "2025-09-04T10:30:00.000Z")`) + } + return parsed.toDate() + }).optional(), + workCount: z.number().optional(), + income: z.number().optional(), + groupId: z.string().optional(), + _id: z.string().optional(), +}) +export class CreateAccountDto extends createZodDto( + CreateAccountSchema, +) {} + +const UpdateAccountSchema = z.object({ + id: z.string({ message: 'ID' }), + name: z.string({ message: '昵称' }).optional(), + avatar: z.string({ message: '头像' }).optional(), + desc: z.string({ message: '简介' }).optional(), + groupId: z.string().optional(), +}) +export class UpdateAccountDto extends createZodDto( + UpdateAccountSchema, +) {} + +const AccountIdSchema = z.object({ + id: z.string({ message: 'ID' }), +}) +export class AccountIdDto extends createZodDto(AccountIdSchema) {} + +export const UpdateAccountStatusSchema = AccountIdSchema.merge( + z.object({ + status: z.enum(AccountStatus), + }), +) +export class UpdateAccountStatusDto extends createZodDto( + UpdateAccountStatusSchema, +) {} + +const AccountListByIdsSchema = z.object({ + ids: z.array(z.string()).describe('账号ID数组'), +}) +export class AccountListByIdsDto extends createZodDto(AccountListByIdsSchema) {} + +const AccountStatisticsSchema = z.object({ + type: z.enum(AccountType).optional().describe('账户类型'), +}) +export class AccountStatisticsDto extends createZodDto(AccountStatisticsSchema) {} + +export class UpdateAccountStatisticsDto { + @ApiProperty({ description: '账号ID' }) + @IsString() + @Expose() + id: string + + @ApiProperty({ description: '作品数' }) + @IsNumber() + @IsOptional() + @Expose() + workCount?: number + + @ApiProperty({ description: '粉丝数' }) + @IsNumber() + @IsOptional() + @Expose() + fansCount?: number + + @ApiProperty({ description: '阅读数' }) + @IsNumber() + @IsOptional() + @Expose() + readCount?: number + + @ApiProperty({ description: '点赞数' }) + @IsNumber() + @IsOptional() + @Expose() + likeCount?: number + + @ApiProperty({ description: '收藏数' }) + @IsNumber() + @IsOptional() + @Expose() + collectCount?: number + + @ApiProperty({ description: '评论数' }) + @IsNumber() + @IsOptional() + @Expose() + commentCount?: number + + @ApiProperty({ description: '收入' }) + @IsNumber() + @IsOptional() + @Expose() + income?: number +} + +export class DeleteAccountsDto { + @ApiProperty({ description: '要删除的ID' }) + @IsArray() + @IsString({ each: true }) + @Expose() + ids: string[] +} + +export class AccountListBySpaceIdsDto { + @ApiProperty({ description: '空间ID数组' }) + @IsArray() + @Expose() + spaceIds: string[] +} + +export class AccountListByTypesDto { + @IsArray({ message: '账号类型必须是数组' }) + @IsEnum(AccountType, { each: true, message: '账号类型值不合法' }) + @Expose() + types: AccountType[] + + @IsEnum(AccountStatus, { message: '状态' }) + @IsOptional() + @Expose() + + readonly status?: AccountStatus +} + +export class AccountListByParamDto { + [key: string]: any +} + +export const SortRankItemSchema = z.object({ + id: z.string({ message: '数据ID' }), + rank: z.number({ message: '序号' }), +}) +export const SortRankSchema = z.object({ + groupId: z.string({ message: '分组ID' }), + list: z.array(SortRankItemSchema), +}) +export class SortRankDto extends createZodDto(SortRankSchema) {} + +export const AccountFilterSchema = z.object({ + userId: z.string().optional(), + types: z.array(z.enum(AccountType)).optional(), +}) +export class AccountFilterDto extends createZodDto(AccountFilterSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/accountGroup.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/accountGroup.dto.ts new file mode 100644 index 000000000..9693ad88b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/account/dto/accountGroup.dto.ts @@ -0,0 +1,41 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +const CreateAccountGroupSchema = z.object({ + name: z.string({ message: '组名称' }), + rank: z.number().optional(), + ip: z.string().optional(), + location: z.string().optional(), + proxyIp: z.string().optional(), + browserConfig: z.record(z.string(), z.any()).optional(), +}) + +export class CreateAccountGroupDto extends createZodDto(CreateAccountGroupSchema) {} + +const UpdateAccountGroupSchema = z.object({ + id: z.string({ message: '更新ID' }), + name: z.string({ message: '组名称' }).optional(), + rank: z.number().optional(), + ip: z.string().optional(), + location: z.string().optional(), + proxyIp: z.string().optional(), + browserConfig: z.record(z.string(), z.any()).optional(), +}) + +export class UpdateAccountGroupDto extends createZodDto(UpdateAccountGroupSchema) {} + +const DeleteAccountGroupSchema = z.object({ + ids: z.array(z.string({ message: 'ID' })), +}) + +export class DeleteAccountGroupDto extends createZodDto(DeleteAccountGroupSchema) {} + +export const SortRankItemSchema = z.object({ + id: z.string({ message: '数据ID' }), + rank: z.number({ message: '序号' }), +}) + +export const SortRankSchema = z.object({ + list: z.array(SortRankItemSchema), +}) +export class SortRankDto extends createZodDto(SortRankSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.controller.ts new file mode 100644 index 000000000..0631ed53f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.controller.ts @@ -0,0 +1,422 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { UserType } from '@yikart/common' +import { AiService } from './ai.service' +import { + ChatCompletionVo, + ChatModelConfigVo, + DashscopeTaskStatusResponseVo, + DashscopeVideoGenerationResponseVo, + FireflycardResponseVo, + ImageEditModelParamsVo, + ImageGenerationModelParamsVo, + ImageResponseVo, + KlingTaskStatusResponseVo, + KlingVideoGenerationResponseVo, + ListVideoTasksResponseVo, + LogListResponseVo, + Md2CardResponseVo, + VideoGenerationModelParamsVo, + VideoGenerationResponseVo, + VideoTaskStatusResponseVo, + VolcengineTaskStatusResponseVo, + VolcengineVideoGenerationResponseVo, +} from './ai.vo' +import { AsyncTaskResponseVo, TaskStatusResponseVo } from './core/image' +import { + ChatCompletionDto, + DashscopeImage2VideoRequestDto, + DashscopeKeyFrame2VideoRequestDto, + DashscopeText2VideoRequestDto, + FireflyCardDto, + ImageEditDto, + ImageGenerationDto, + KlingImage2VideoRequestDto, + KlingMultiImage2VideoRequestDto, + KlingText2VideoRequestDto, + LogListQueryDto, + Md2CardDto, + UserListVideoTasksQueryDto, + VideoGenerationRequestDto, + VolcengineGenerationRequestDto, +} from './dto' + +@ApiTags('AI') +@Controller('ai') +export class AiController { + constructor(private readonly aiService: AiService) {} + + @ApiOperation({ summary: 'AI聊天对话' }) + @Post('chat') + async chat(@GetToken() token: TokenInfo, @Body() body: ChatCompletionDto): Promise { + const response = await this.aiService.userAiChat({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return ChatCompletionVo.create(response) + } + + @ApiOperation({ summary: '获取用户AI使用日志' }) + @Get('logs') + async getLogs( + @GetToken() token: TokenInfo, + @Query() query: LogListQueryDto, + ): Promise { + const response = await this.aiService.getUserLogs({ + userId: token.id, + userType: UserType.User, + ...query, + }) + return LogListResponseVo.create(response) + } + + @ApiOperation({ summary: 'AI图片生成' }) + @Post('image/generate') + async generateImage( + @GetToken() token: TokenInfo, + @Body() body: ImageGenerationDto, + ): Promise { + const response = await this.aiService.userImageGeneration({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return ImageResponseVo.create(response) + } + + @ApiOperation({ summary: 'AI图片编辑' }) + @Post('image/edit') + async editImage( + @GetToken() token: TokenInfo, + @Body() body: ImageEditDto, + ): Promise { + const response = await this.aiService.userImageEdit({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return ImageResponseVo.create(response) + } + + @ApiOperation({ summary: '异步AI图片生成' }) + @Post('image/generate/async') + async generateImageAsync( + @GetToken() token: TokenInfo, + @Body() body: ImageGenerationDto, + ) { + const response = await this.aiService.userImageGenerationAsync({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return AsyncTaskResponseVo.create(response) + } + + @ApiOperation({ summary: '异步AI图片编辑' }) + @Post('image/edit/async') + async editImageAsync( + @GetToken() token: TokenInfo, + @Body() body: ImageEditDto, + ) { + const response = await this.aiService.userImageEditAsync({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return AsyncTaskResponseVo.create(response) + } + + @ApiOperation({ summary: '异步Markdown转卡片图片' }) + @Post('md2card/async') + async generateMd2CardAsync( + @GetToken() token: TokenInfo, + @Body() body: Md2CardDto, + ) { + const response = await this.aiService.generateMd2CardAsync({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return AsyncTaskResponseVo.create(response) + } + + @ApiOperation({ summary: '异步Fireflycard生成卡片图片(免费)' }) + @Post('fireflycard/async') + async generateFireflycardAsync( + @GetToken() token: TokenInfo, + @Body() body: FireflyCardDto, + ) { + const response = await this.aiService.generateFireflycardAsync({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return AsyncTaskResponseVo.create(response) + } + + @ApiOperation({ summary: '查询图片任务状态' }) + @Get('image/task/:logId') + async getImageTaskStatus( + @GetToken() token: TokenInfo, + @Param('logId') logId: string, + ): Promise { + const response = await this.aiService.getImageTaskStatus(logId) + return TaskStatusResponseVo.create(response) + } + + @ApiOperation({ summary: '通用视频生成' }) + @Post('video/generations') + async videoGeneration( + @GetToken() token: TokenInfo, + @Body() body: VideoGenerationRequestDto, + ): Promise { + const response = await this.aiService.userVideoGeneration({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return VideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '查询视频任务状态' }) + @Get('video/generations/:taskId') + async getVideoTaskStatus( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ): Promise { + const response = await this.aiService.getVideoTaskStatus({ + userId: token.id, + userType: UserType.User, + taskId, + }) + return VideoTaskStatusResponseVo.create(response) + } + + @ApiOperation({ summary: '视频任务列表' }) + @Get('video/generations') + async listVideoTasks( + @GetToken() token: TokenInfo, + @Query() query: UserListVideoTasksQueryDto, + ): Promise { + const response = await this.aiService.listVideoTasks({ + userId: token.id, + userType: UserType.User, + ...query, + }) + return ListVideoTasksResponseVo.create(response) + } + + @ApiOperation({ summary: '火山视频生成' }) + @Post('volcengine/video') + async volcVideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: VolcengineGenerationRequestDto, + ): Promise { + const response = await this.aiService.volcVideoGeneration({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return VolcengineVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '查询火山视频任务状态' }) + @Get('volcengine/video/:taskId') + async volcVideoTaskStatus( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ): Promise { + const response = await this.aiService.volcVideoTaskStatus({ + userId: token.id, + userType: UserType.User, + taskId, + }) + return VolcengineTaskStatusResponseVo.create(response) + } + + @ApiOperation({ summary: '可灵文本到视频生成' }) + @Post('kling/text2video') + async klingVideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: KlingText2VideoRequestDto, + ): Promise { + const response = await this.aiService.klingText2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return KlingVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '可灵图片到视频生成' }) + @Post('kling/image2video') + async klingImage2VideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: KlingImage2VideoRequestDto, + ): Promise { + const response = await this.aiService.klingImage2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return KlingVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '可灵多图片到视频生成' }) + @Post('kling/multi-image2video') + async klingMultiImage2VideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: KlingMultiImage2VideoRequestDto, + ): Promise { + const response = await this.aiService.klingMultiImage2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return KlingVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '可灵查询任务状态' }) + @Get('kling/:taskId') + async getKlingTaskStatus( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ): Promise { + const response = await this.aiService.getKlingTaskStatus({ + userId: token.id, + userType: UserType.User, + taskId, + }) + return KlingTaskStatusResponseVo.create(response) + } + + @ApiOperation({ summary: 'Markdown转卡片图片' }) + @Post('md2card') + async generateMd2Card( + @GetToken() token: TokenInfo, + @Body() body: Md2CardDto, + ): Promise { + const response = await this.aiService.generateMd2Card({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return Md2CardResponseVo.create(response) + } + + @ApiOperation({ summary: 'Fireflycard生成卡片图片(免费)' }) + @Post('fireflycard') + async generateFireflycard( + @GetToken() token: TokenInfo, + @Body() body: FireflyCardDto, + ): Promise { + const response = await this.aiService.generateFireflycard({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return FireflycardResponseVo.create(response) + } + + @ApiOperation({ summary: '获取图片生成模型参数' }) + @Public() + @Get('models/image/generation') + async getImageGenerationModels(@GetToken() token?: TokenInfo): Promise { + const response = await this.aiService.getImageGenerationModels({ + userId: token?.id, + userType: UserType.User, + }) + return response.map((item: { name: string, description: string, sizes: string[], qualities: string[], styles: string[], pricing: string, summary?: string | undefined, logo?: string | undefined, tags?: string[] | undefined, mainTag?: string | undefined, discount?: string | undefined, originPrice?: string | undefined }) => ImageGenerationModelParamsVo.create(item)) + } + + @ApiOperation({ summary: '获取图片编辑模型参数' }) + @Public() + @Get('models/image/edit') + async getImageEditModels(@GetToken() token?: TokenInfo): Promise { + const response = await this.aiService.getImageEditModels({ + userId: token?.id, + userType: UserType.User, + }) + return response.map((item: { name: string, description: string, sizes: string[], pricing: string, maxInputImages: number, summary?: string | undefined, logo?: string | undefined, tags?: string[] | undefined, mainTag?: string | undefined, discount?: string | undefined, originPrice?: string | undefined }) => ImageEditModelParamsVo.create(item)) + } + + @ApiOperation({ summary: '获取视频生成模型参数' }) + @Public() + @Get('models/video/generation') + async getVideoGenerationModels(@GetToken() token?: TokenInfo): Promise { + const response = await this.aiService.getVideoGenerationModels({ + userId: token?.id, + userType: UserType.User, + }) + return response.map((item: { name: string, description: string, modes: ('text2video' | 'image2video' | 'flf2video' | 'lf2video' | 'multi-image2video')[], channel: any, resolutions: string[], durations: number[], supportedParameters: string[], pricing: { price: number, resolution?: string | undefined, aspectRatio?: string | undefined, mode?: string | undefined, duration?: number | undefined, discount?: string | undefined, originPrice?: number | undefined }[], summary?: string | undefined, logo?: string | undefined, tags?: string[] | undefined, mainTag?: string | undefined, defaults?: { resolution?: string | undefined, aspectRatio?: string | undefined, mode?: string | undefined, duration?: number | undefined } | undefined }) => VideoGenerationModelParamsVo.create(item)) + } + + @ApiOperation({ summary: '获取对话模型参数' }) + @Public() + @Get('models/chat') + async getChatModels(@GetToken() token?: TokenInfo): Promise { + const response = await this.aiService.getChatModels({ + userId: token?.id, + userType: UserType.User, + }) + return response.map((item: { name: string, description: string, inputModalities: ('image' | 'text' | 'video' | 'audio')[], outputModalities: ('image' | 'text' | 'video' | 'audio')[], pricing: { prompt: string, completion: string, discount?: string | undefined, originPrompt?: string | undefined, originCompletion?: string | undefined, image?: string | undefined, originImage?: string | undefined, audio?: string | undefined, originAudio?: string | undefined } | { price: string, discount?: string | undefined, originPrice?: string | undefined }, summary?: string | undefined, logo?: string | undefined, tags?: string[] | undefined, mainTag?: string | undefined }) => ChatModelConfigVo.create(item)) + } + + @ApiOperation({ summary: 'Dashscope文本到视频生成' }) + @Post('dashscope/text2video') + async dashscopeText2VideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: DashscopeText2VideoRequestDto, + ): Promise { + const response = await this.aiService.dashscopeText2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return DashscopeVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: 'Dashscope图片到视频生成' }) + @Post('dashscope/image2video') + async dashscopeImage2VideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: DashscopeImage2VideoRequestDto, + ): Promise { + const response = await this.aiService.dashscopeImage2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return DashscopeVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: 'Dashscope首尾帧到视频生成' }) + @Post('dashscope/keyframe2video') + async dashscopeKeyFrame2VideoGeneration( + @GetToken() token: TokenInfo, + @Body() body: DashscopeKeyFrame2VideoRequestDto, + ): Promise { + const response = await this.aiService.dashscopeKeyFrame2Video({ + userId: token.id, + userType: UserType.User, + ...body, + }) + return DashscopeVideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '查询Dashscope任务状态' }) + @Get('dashscope/:taskId') + async getDashscopeTaskStatus( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ): Promise { + const response = await this.aiService.getDashscopeTaskStatus({ + userId: token.id, + userType: UserType.User, + taskId, + }) + return DashscopeTaskStatusResponseVo.create(response) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.module.ts new file mode 100644 index 000000000..062b5c608 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common' +import { config } from '../config' +import { AiController } from './ai.controller' +import { AiService } from './ai.service' +import { ChatModule } from './core/chat' +import { ImageModule } from './core/image' +import { ModelsConfigModule } from './core/models-config' +import { VideoModule } from './core/video' +import { OpenaiModule } from './libs/openai' +import { SchedulerModule } from './scheduler' + +@Module({ + imports: [ + OpenaiModule.forRoot(config.ai.openai), + SchedulerModule, + ChatModule, + ImageModule, + VideoModule, + ModelsConfigModule, + ], + controllers: [AiController], + providers: [AiService], + exports: [AiService], +}) +export class AiModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.service.ts new file mode 100644 index 000000000..d58d78a5d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.service.ts @@ -0,0 +1,497 @@ +import { Injectable, Logger } from '@nestjs/common' +import { UserType } from '@yikart/common' +import OpenAI from 'openai' +import { LogListResponseVo } from './ai.vo' +import { ChatModelConfigVo, ChatModelsQueryDto, ChatService, UserChatCompletionDto } from './core/chat' +import { FireflycardResponseVo, ImageEditModelParamsVo, ImageEditModelsQueryDto, ImageGenerationModelParamsVo, ImageGenerationModelsQueryDto, ImageResponseVo, ImageService, Md2CardResponseVo, TaskStatusResponseVo, UserFireflyCardDto, UserImageEditDto, UserImageGenerationDto, UserMd2CardDto } from './core/image' +import { LogListQueryDto, LogsService } from './core/logs' +import { DashscopeImage2VideoRequestDto, DashscopeKeyFrame2VideoRequestDto, DashscopeTaskQueryDto, DashscopeTaskStatusResponseVo, DashscopeText2VideoRequestDto, DashscopeVideoGenerationResponseVo, KlingImage2VideoRequestDto, KlingMultiImage2VideoRequestDto, KlingTaskQueryDto, KlingTaskStatusResponseVo, KlingText2VideoRequestDto, KlingVideoGenerationResponseVo, ListVideoTasksResponseVo, UserListVideoTasksQueryDto, UserVideoGenerationRequestDto, UserVideoTaskQueryDto, VideoGenerationModelParamsVo, VideoGenerationModelsQueryDto, VideoGenerationResponseVo, VideoService, VideoTaskStatusResponseVo, VolcengineGenerationRequestDto, VolcengineTaskQueryDto, VolcengineTaskStatusResponseVo, VolcengineVideoGenerationResponseVo } from './core/video' + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name) + constructor( + private readonly chatService: ChatService, + private readonly logsService: LogsService, + private readonly imageService: ImageService, + private readonly videoService: VideoService, + ) { } + + /** + * 用户AI聊天 + * @param request 聊天请求参数 + * @returns AI聊天响应 + */ + async userAiChat(request: UserChatCompletionDto) { + const response = await this.chatService.userChatCompletion(request) + return response + } + + /** + * 获取用户AI使用日志 + * @param request 日志查询请求参数 + * @returns 用户日志响应 + */ + async getUserLogs(request: LogListQueryDto) { + const [list, total] = await this.logsService.getLogList(request) + return new LogListResponseVo(list, total, request) + } + + /** + * 用户图片生成 + * @param request 图片生成请求参数 + * @returns 图片生成响应 + */ + async userImageGeneration(request: UserImageGenerationDto) { + const response = await this.imageService.userGeneration(request) + return ImageResponseVo.create(response) + } + + /** + * 用户图片编辑 + * @param request 图片编辑请求参数 + * @returns 图片编辑响应 + */ + async userImageEdit(request: UserImageEditDto) { + const response = await this.imageService.userEdit(request) + return ImageResponseVo.create(response) + } + + /** + * 异步用户图片生成 + * @param request 图片生成请求参数 + * @returns 异步任务响应 + */ + async userImageGenerationAsync(request: UserImageGenerationDto) { + const response = await this.imageService.userGenerationAsync(request) + return response + } + + /** + * 异步用户图片编辑 + * @param request 图片编辑请求参数 + * @returns 异步任务响应 + */ + async userImageEditAsync(request: UserImageEditDto) { + const response = await this.imageService.userEditAsync(request) + return response + } + + /** + * 异步Markdown转卡片图片 + * @param request MD2Card请求参数 + * @returns 异步任务响应 + */ + async generateMd2CardAsync(request: UserMd2CardDto) { + const response = await this.imageService.userMd2CardAsync(request) + return response + } + + /** + * 异步Fireflycard生成卡片图片 + * @param request Fireflycard请求参数 + * @returns 异步任务响应 + */ + async generateFireflycardAsync(request: UserFireflyCardDto) { + const response = await this.imageService.userFireFlyCardAsync(request) + return response + } + + /** + * 查询图片任务状态 + * @param logId 任务日志ID + * @returns 任务状态响应 + */ + async getImageTaskStatus(logId: string) { + const response = await this.imageService.getTaskStatus(logId) + return TaskStatusResponseVo.create(response) + } + + /** + * 通用视频生成 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async userVideoGeneration(request: UserVideoGenerationRequestDto) { + const response = await this.videoService.userVideoGeneration(request) + return VideoGenerationResponseVo.create(response) + } + + /** + * 查询视频任务状态 + * @param request 视频任务查询请求参数 + * @returns 视频任务状态响应 + */ + async getVideoTaskStatus(request: UserVideoTaskQueryDto) { + const response = await this.videoService.getVideoTaskStatus(request) + return VideoTaskStatusResponseVo.create(response) + } + + /** + * 查询视频任务状态 + * @param request 视频任务查询请求参数 + * @returns 视频任务状态响应 + */ + async listVideoTasks(request: UserListVideoTasksQueryDto) { + const [list, total] = await this.videoService.listVideoTasks(request) + return new ListVideoTasksResponseVo(list, total, request) + } + + /** + * 火山视频生成 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async volcVideoGeneration(request: VolcengineGenerationRequestDto) { + const response = await this.videoService.volcengineCreate(request) + return VolcengineVideoGenerationResponseVo.create(response) + } + + /** + * 火山视频生成 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async volcVideoTaskStatus(request: VolcengineTaskQueryDto) { + const response = await this.videoService.getVolcengineTask(request.userId, request.userType, request.taskId) + return VolcengineTaskStatusResponseVo.create(response) + } + + /** + * 可灵文本到视频生成 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async klingText2Video(request: KlingText2VideoRequestDto) { + const response = await this.videoService.klingText2Video(request) + return KlingVideoGenerationResponseVo.create(response) + } + + /** + * 可灵图生视频 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async klingImage2Video(request: KlingImage2VideoRequestDto) { + const response = await this.videoService.klingImage2Video(request) + return KlingVideoGenerationResponseVo.create(response) + } + + /** + * 可灵多图生视频 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async klingMultiImage2Video(request: KlingMultiImage2VideoRequestDto) { + const response = await this.videoService.klingMultiImage2Video(request) + return KlingVideoGenerationResponseVo.create(response) + } + + /** + * 可灵视频生成 + * @param request 视频生成请求参数 + * @returns 视频生成响应 + */ + async getKlingTaskStatus(request: KlingTaskQueryDto) { + const response = await this.videoService.getKlingTask(request.userId, request.userType, request.taskId) + return KlingTaskStatusResponseVo.create(response) + } + + /** + * Markdown转卡片图片 + * @param request MD2Card请求参数 + * @returns MD2Card生成结果 + */ + async generateMd2Card(request: UserMd2CardDto) { + const response = await this.imageService.userMd2Card(request) + return Md2CardResponseVo.create(response) + } + + /** + * Fireflycard生成卡片图片 + * @param request Fireflycard请求参数 + * @returns Fireflycard生成结果 + */ + async generateFireflycard(request: UserFireflyCardDto) { + const response = await this.imageService.userFireFlyCard(request) + return FireflycardResponseVo.create(response) + } + + /** + * 获取图片生成模型参数 + * @param data 查询参数 + * @returns 图片生成模型参数列表 + */ + async getImageGenerationModels(data?: ImageGenerationModelsQueryDto) { + const response = await this.imageService.generationModelConfig(data || {}) + return response.map((item: { name: string, description: string, sizes: string[], qualities: string[], styles: string[], pricing: string }) => ImageGenerationModelParamsVo.create(item)) + } + + /** + * 获取图片编辑模型参数 + * @param data 查询参数 + * @returns 图片编辑模型参数列表 + */ + async getImageEditModels(data?: ImageEditModelsQueryDto) { + const response = await this.imageService.editModelConfig(data || {}) + return response.map((item: { name: string, description: string, sizes: string[], pricing: string, maxInputImages: number }) => ImageEditModelParamsVo.create(item)) + } + + /** + * 获取视频生成模型参数 + * @param data 查询参数 + * @returns 视频生成模型参数列表 + */ + async getVideoGenerationModels(data?: VideoGenerationModelsQueryDto) { + const response = await this.videoService.getVideoGenerationModelParams(data || {}) + return response.map((item: VideoGenerationModelParamsVo) => VideoGenerationModelParamsVo.create(item)) + } + + /** + * 获取对话模型参数 + * @param data 查询参数 + * @returns 对话模型参数列表 + */ + async getChatModels(data?: ChatModelsQueryDto) { + const response = await this.chatService.getChatModelConfig(data || {}) + return response.map((item: ChatModelConfigVo) => ChatModelConfigVo.create(item)) + } + + /** + * Dashscope 文生视频 + * @param request 文生视频请求参数 + * @returns 视频生成响应 + */ + async dashscopeText2Video(request: DashscopeText2VideoRequestDto) { + const response = await this.videoService.dashscopeText2Video(request) + return DashscopeVideoGenerationResponseVo.create(response) + } + + /** + * Dashscope 图生视频 + * @param request 图生视频请求参数 + * @returns 视频生成响应 + */ + async dashscopeImage2Video(request: DashscopeImage2VideoRequestDto) { + const response = await this.videoService.dashscopeImage2Video(request) + return DashscopeVideoGenerationResponseVo.create(response) + } + + /** + * Dashscope 首尾帧生视频 + * @param request 首尾帧生视频请求参数 + * @returns 视频生成响应 + */ + async dashscopeKeyFrame2Video(request: DashscopeKeyFrame2VideoRequestDto) { + const response = await this.videoService.dashscopeKeyFrame2Video(request) + return DashscopeVideoGenerationResponseVo.create(response) + } + + /** + * 查询 Dashscope 任务状态 + * @param request 任务查询请求参数 + * @returns 任务状态响应 + */ + async getDashscopeTaskStatus(request: DashscopeTaskQueryDto) { + const response = await this.videoService.getDashscopeTask(request.userId, request.userType, request.taskId) + return DashscopeTaskStatusResponseVo.create(response) + } + + // 智能图片文案 + async imgContentByAi(user: { userId: string, userType: UserType }, model: string, imgUrl: string, prompt: string, option: { + title?: string + desc?: string + max?: number + language?: string + }): Promise { + const { userId, userType } = user + + const systemContent = `Generate copy based on the pictures and prompt words, as well as the reference titles and contents. Reply in ${option.language || 'English'}. The reply should not exceed ${option.max || 100} characters. Just return the copy.` + let text = `prompt${prompt}.` + if (option.title) + text += `Reference Title: ${option.title}` + if (option.desc) + text += `Reference description: ${option.desc}` + + const request: UserChatCompletionDto = { + userId, + userType, + model, + messages: [ + { + role: 'system', + content: systemContent, + }, + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { + url: imgUrl, + }, + }, + { + type: 'text', + text, + }, + ], + }, + ], + } + + try { + const res = await this.userAiChat(request) + return res.content as string || '' + } + catch (error) { + this.logger.log({ data: error, path: '======= imgContentByAi error =======' }) + return '' + } + } + + // 智能视频文案 + async videoContentByAi(user: { userId: string, userType: UserType }, model: string, videoUrl: string, prompt: string, option: { + title?: string + desc?: string + max?: number + language?: string + }): Promise { + const { userId, userType } = user + + const systemContent = `Generate copy based on the video and prompt words, as well as the reference titles and contents. Reply in ${option.language || 'English'}. The reply should not exceed ${option.max || 100} characters. Just return the copy.` + let text = `prompt${prompt}.` + if (option.title) + text += `Reference Title: ${option.title}` + if (option.desc) + text += `Reference description: ${option.desc}` + + const request: UserChatCompletionDto = { + userId, + userType, + model, + messages: [ + { + role: 'system', + content: systemContent, + }, + { + role: 'user', + content: [ + { + type: 'video', + video_url: { + url: videoUrl, + }, + }, + { + type: 'text', + text, + }, + ], + }, + ], + } + + const res = await this.userAiChat(request) + return res.content as string || '' + } + + // 根据图片返回文字内容 + async getContentByAi(user: { userId: string, userType: UserType }, model: string, prompt: string, option: { + coverUrl?: string + title?: string + desc?: string + max?: number + language?: string + }): Promise { + const { userId, userType } = user + if (!option.desc && !option.coverUrl) + return '' + + const systemContent = `Based on the cover image, refer to the title and the original content to generate beautiful copy. Reply in ${option.language || 'English'} and the content of your reply should not exceed ${option.max || 100} words. Just return the copy` + + let text = `prompt${prompt}.` + if (option.title) + text += `Reference Title: ${option.title}` + if (option.desc) + text += `Reference description: ${option.desc}` + + const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [ + { + type: 'text', + text, + }, + ] + + if (option.coverUrl) { + content.push({ + type: 'image_url', + image_url: { + url: option.coverUrl, + }, + }) + } + const request: UserChatCompletionDto = { + userId, + userType, + model, + messages: [ + { + role: 'system', + content: systemContent, + }, + { + role: 'user', + content, + }, + ], + } + + const res = await this.userAiChat(request) + return res.content as string || '' + } + + // 根据图片返回文字内容 + async getTitleByAi(user: { userId: string, userType: UserType }, model: string, desc: string, option: { + title?: string + max?: number + language?: string + }): Promise { + const { userId, userType } = user + if (!desc) + return '' + + const systemContent = `Generate the content title based on the reference title and the original content. Please reply in ${option.language || 'English'} and the content of your reply should not exceed ${option.max || 100} words. Just return the title text` + + let text = `Original content: ${desc}. ` + if (option.title) + text += `Reference Title: ${option.title}` + + const content: OpenAI.Chat.Completions.ChatCompletionContentPart[] = [ + { + type: 'text', + text, + }, + ] + + const request: UserChatCompletionDto = { + userId, + userType, + model, + messages: [ + { + role: 'system', + content: systemContent, + }, + { + role: 'user', + content, + }, + ], + } + + const res = await this.userAiChat(request) + return res.content as string || '' + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.vo.ts new file mode 100644 index 000000000..b73d3a7b1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/ai.vo.ts @@ -0,0 +1,434 @@ +import { createPaginationVo, createZodDto, UserType } from '@yikart/common' +import { AiLogChannel, AiLogStatus, AiLogType } from '@yikart/mongodb' +import { z } from 'zod' +import { messageContentComplexSchema } from './dto' +import { TaskStatus } from './libs/dashscope' + +const modalitiesTokenDetails = z.object({ + text: z.number().optional(), + image: z.number().optional(), + audio: z.number().optional(), + video: z.number().optional(), + document: z.number().optional(), +}) + +const chatCompletionVoSchema = z.object({ + content: z + .union([z.string(), z.array(messageContentComplexSchema)]) + .describe('生成内容'), + model: z.string().optional().describe('使用的模型'), + usage: z + .object({ + points: z.number().optional(), + input_tokens: z.number().optional().describe('输入token数'), + output_tokens: z.number().optional().describe('输出token数'), + total_tokens: z.number().optional().describe('总token数'), + input_token_details: z + .object({ + ...modalitiesTokenDetails.shape, + cache_read: z.number().optional(), + cache_creation: z.number().optional(), + }) + .optional(), + output_token_details: z + .object({ + ...modalitiesTokenDetails.shape, + reasoning: z.number().optional(), + }) + .optional(), + }) + .optional() + .describe('token 使用情况'), +}) + +export class ChatCompletionVo extends createZodDto(chatCompletionVoSchema) {} + +// 图片编辑模型参数 VO +const chatModelSchema = z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + inputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + outputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + pricing: z.union([ + z.object({ + discount: z.string().optional(), + prompt: z.string(), + originPrompt: z.string().optional(), + completion: z.string(), + originCompletion: z.string().optional(), + image: z.string().optional(), + originImage: z.string().optional(), + audio: z.string().optional(), + originAudio: z.string().optional(), + }), + z.object({ + price: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + }), + ]), +}) + +export class ChatModelConfigVo extends createZodDto(chatModelSchema) {} + +// 使用情况统计 +const usageMetadataSchema = z.object({ + input_tokens: z.number().optional().describe('输入token数'), + output_tokens: z.number().optional().describe('输出token数'), + total_tokens: z.number().optional().describe('总token数'), +}) + +// 图片对象 +const imageObjectSchema = z.object({ + url: z.string().optional().describe('图片URL'), + b64_json: z.string().optional().describe('base64编码的图片'), + revised_prompt: z.string().optional().describe('修订后的提示词'), +}) + +// 用户图片响应 +const userImageResponseSchema = z.object({ + created: z.number().describe('创建时间戳'), + list: z.array(imageObjectSchema).describe('生成的图片列表'), + usage: usageMetadataSchema.optional().describe('token使用情况'), +}) + +export class ImageResponseVo extends createZodDto(userImageResponseSchema) {} + +// 图片生成模型参数 VO +const imageGenerationModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()).describe('支持的尺寸'), + qualities: z.array(z.string()).describe('支持的质量选项'), + styles: z.array(z.string()).describe('支持的风格选项'), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), +}) + +export class ImageGenerationModelParamsVo extends createZodDto( + imageGenerationModelSchema, +) {} + +// 图片编辑模型参数 VO +const imageEditModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()).describe('支持的尺寸'), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + maxInputImages: z.number(), +}) + +export class ImageEditModelParamsVo extends createZodDto( + imageEditModelSchema, +) {} + +// MD2Card生成响应 +const md2CardResponseSchema = z.object({ + images: z + .array( + z.object({ + url: z.string().describe('图片URL'), + fileName: z.string().describe('文件名'), + }), + ) + .describe('生成的卡片图片'), +}) + +export class Md2CardResponseVo extends createZodDto(md2CardResponseSchema) {} + +// Fireflycard生成响应 +const fireflycardResponseSchema = z.object({ + image: z.string().describe('生成的卡片图片base64数据'), +}) + +export class FireflycardResponseVo extends createZodDto( + fireflycardResponseSchema, +) {} +// 日志基本信息 +const logItemSchema = z.object({ + id: z.string().describe('日志ID'), + userId: z.string().describe('用户ID'), + userType: z.enum(UserType).describe('用户类型'), + taskId: z.string().optional().describe('任务ID'), + type: z.enum(AiLogType).optional().describe('日志类型'), + model: z.string().describe('模型'), + channel: z.enum(AiLogChannel).describe('渠道'), + action: z.string().optional().describe('操作'), + status: z.enum(AiLogStatus).describe('日志状态'), + startedAt: z.date().describe('开始时间'), + duration: z.number().optional().describe('持续时间'), + points: z.number().describe('积分'), + createdAt: z.date().describe('创建时间'), + updatedAt: z.date().describe('更新时间'), +}) + +export class LogItemVo extends createZodDto(logItemSchema) {} + +// 日志列表响应 +export class LogListResponseVo extends createPaginationVo( + logItemSchema, + 'LogListResponseVo', +) {} + +// 日志详情响应 +const logDetailResponseSchema = z.object({ + id: z.string().describe('日志ID'), + taskId: z.string().describe('任务ID'), + type: z.enum(AiLogType).describe('日志类型'), + model: z.string().describe('模型'), + channel: z.enum(AiLogChannel).describe('渠道'), + action: z.string().optional().describe('操作'), + status: z.enum(AiLogStatus).describe('日志状态'), + startedAt: z.date().describe('开始时间'), + duration: z.number().optional().describe('持续时间'), + request: z.record(z.string(), z.unknown()).describe('请求参数'), + response: z.record(z.string(), z.unknown()).optional().describe('响应结果'), + errorMessage: z.string().optional().describe('错误信息'), + points: z.number().describe('积分'), + createdAt: z.date().describe('创建时间'), + updatedAt: z.date().describe('更新时间'), +}) + +export class LogDetailResponseVo extends createZodDto( + logDetailResponseSchema, +) {} +// Kling视频生成响应 +const klingVideoGenerationResponseSchema = z.object({ + task_id: z.string(), + task_status: z.string().optional(), +}) + +// Volcengine视频生成响应 +const volcengineVideoGenerationResponseSchema = z.object({ + id: z.string(), +}) + +// 通用视频生成响应 +const videoGenerationResponseSchema = z.object({ + task_id: z.string(), + status: z.string(), +}) + +export class KlingVideoGenerationResponseVo extends createZodDto( + klingVideoGenerationResponseSchema, +) {} +export class VolcengineVideoGenerationResponseVo extends createZodDto( + volcengineVideoGenerationResponseSchema, +) {} +export class VideoGenerationResponseVo extends createZodDto( + videoGenerationResponseSchema, +) {} + +// Kling 任务状态响应 VO +const klingTaskStatusResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.string().describe('任务状态'), + task_status_msg: z.string().describe('任务状态信息'), + task_info: z + .object({ + parent_video: z + .object({ + id: z.string(), + url: z.string(), + duration: z.string(), + }) + .optional(), + external_task_id: z.string().optional(), + }) + .optional() + .describe('任务信息'), + task_result: z + .object({ + images: z + .array( + z.object({ + index: z.number(), + url: z.string(), + }), + ) + .optional(), + videos: z + .array( + z.object({ + id: z.string(), + url: z.string(), + duration: z.string(), + }), + ) + .optional(), + }) + .optional() + .describe('任务结果'), + created_at: z.number().describe('创建时间'), + updated_at: z.number().describe('更新时间'), +}) + +export class KlingTaskStatusResponseVo extends createZodDto( + klingTaskStatusResponseSchema, +) {} + +// Volcengine 任务状态响应 VO +const volcengineTaskStatusResponseSchema = z.object({ + id: z.string().describe('任务ID'), + model: z.string().describe('模型名称'), + status: z.string().describe('任务状态'), + error: z + .object({ + message: z.string(), + code: z.string(), + }) + .nullable() + .describe('错误信息'), + created_at: z.number().describe('创建时间'), + updated_at: z.number().describe('更新时间'), + content: z + .object({ + video_url: z.string().optional(), + last_frame_url: z.string().optional(), + }) + .optional() + .describe('视频内容'), + seed: z.number().optional().describe('种子值'), + resolution: z.string().optional().describe('分辨率'), + ratio: z.string().optional().describe('宽高比'), + duration: z.number().optional().describe('时长'), + framespersecond: z.number().optional().describe('帧率'), + usage: z + .object({ + completion_tokens: z.number().optional(), + total_tokens: z.number().optional(), + }) + .optional() + .describe('使用量统计'), +}) + +export class VolcengineTaskStatusResponseVo extends createZodDto( + volcengineTaskStatusResponseSchema, +) {} + +// 通用视频任务状态响应 +const videoTaskStatusResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + action: z.string().describe('任务动作'), + status: z.string().describe('任务状态'), + fail_reason: z.string().optional().describe('失败原因或视频URL'), + submit_time: z.number().describe('提交时间'), + start_time: z.number().describe('开始时间'), + finish_time: z.number().describe('完成时间'), + progress: z.string().describe('任务进度'), + data: z.any(), +}) + +export class VideoTaskStatusResponseVo extends createZodDto( + videoTaskStatusResponseSchema, +) {} + +export class ListVideoTasksResponseVo extends createPaginationVo( + videoTaskStatusResponseSchema, +) {} + +// 视频生成模型参数 VO +const videoGenerationModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + modes: z + .array( + z.enum([ + 'text2video', + 'image2video', + 'flf2video', + 'lf2video', + 'multi-image2video', + ]), + ) + .describe('支持的模式'), + channel: z.enum(AiLogChannel), + resolutions: z.array(z.string()).describe('支持的尺寸'), + durations: z.array(z.number()).describe('支持的时长'), + supportedParameters: z.array(z.string()).describe('支持的参数'), + defaults: z + .object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + }) + .optional(), + pricing: z + .object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + price: z.number(), + discount: z.string().optional(), + originPrice: z.number().optional(), + }) + .array(), +}) + +export class VideoGenerationModelParamsVo extends createZodDto( + videoGenerationModelSchema, +) {} + +// Dashscope 视频生成响应 VO +const dashscopeVideoGenerationResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.enum(TaskStatus).optional().describe('任务状态'), +}) + +export class DashscopeVideoGenerationResponseVo extends createZodDto( + dashscopeVideoGenerationResponseSchema, +) {} + +// Dashscope 任务状态响应 VO +const dashscopeTaskStatusResponseSchema = z.object({ + status_code: z.number().describe('HTTP状态码'), + request_id: z.string().describe('请求ID'), + code: z.string().nullable().describe('错误码'), + message: z.string().describe('错误消息'), + output: z + .object({ + task_id: z.string().describe('任务ID'), + task_status: z.enum(TaskStatus).describe('任务状态'), + video_url: z.string().optional().describe('视频URL'), + submit_time: z.string().optional().describe('任务提交时间'), + scheduled_time: z.string().optional().describe('任务调度时间'), + end_time: z.string().optional().describe('任务结束时间'), + orig_prompt: z.string().optional().describe('原始提示词'), + actual_prompt: z.string().optional().describe('实际使用的提示词'), + }) + .describe('输出结果'), + usage: z + .object({ + video_count: z.number().describe('视频数量'), + video_duration: z.number().describe('视频时长'), + video_ratio: z.string().describe('视频分辨率'), + }) + .nullable() + .optional() + .describe('使用量统计'), +}) + +export class DashscopeTaskStatusResponseVo extends createZodDto( + dashscopeTaskStatusResponseSchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/dashscope-action.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/dashscope-action.enum.ts new file mode 100644 index 000000000..114cb6949 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/dashscope-action.enum.ts @@ -0,0 +1,5 @@ +export enum DashscopeAction { + Text2Video = 'text2video', + Image2Video = 'image2video', + KeyFrame2Video = 'keyframe2video', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/index.ts new file mode 100644 index 000000000..b4a3d9e56 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/index.ts @@ -0,0 +1,3 @@ +export * from './dashscope-action.enum' +export * from './kling-action.enum' +export * from './new-api-task-status.enum' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/kling-action.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/kling-action.enum.ts new file mode 100644 index 000000000..63eda045e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/kling-action.enum.ts @@ -0,0 +1,9 @@ +export enum KlingAction { + Text2Video = 'text2video', + Image2video = 'image2video', + MultiImage2video = 'multi-image2video', + MultiElements = 'multi-elements', + VideoExtend = 'video-extend', + LipSync = 'lip-sync', + Effects = 'effects', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/new-api-task-status.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/new-api-task-status.enum.ts new file mode 100644 index 000000000..dccfbaf2f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/enums/new-api-task-status.enum.ts @@ -0,0 +1,9 @@ +export enum TaskStatus { + NotStart = 'NOT_START', + Submitted = 'SUBMITTED', + Queued = 'QUEUED', + InProgress = 'InProgress', + Failure = 'FAILURE', + Success = 'SUCCESS', + Unknown = 'UNKNOWN', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/utils/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/common/utils/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.dto.ts new file mode 100644 index 000000000..062a5dad8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.dto.ts @@ -0,0 +1,63 @@ +import { createZodDto, UserType } from '@yikart/common' +import { z } from 'zod' + +export const messageContentTextSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}) + +export const messageContentImageUrlSchema = z.object({ + type: z.literal('image_url'), + image_url: z.object({ + url: z.url(), + detail: z.enum(['auto', 'low', 'high']).optional(), + }), +}) +const complexObjectSchema = z.record(z.string(), z.any()).and(z.object({ + type: z.string().optional(), +})) + +const genericObjectSchema = z.record(z.string(), z.any()).and(z.object({ + type: z.undefined(), +})) + +export const messageContentComplexSchema = z.union([ + messageContentTextSchema, + messageContentImageUrlSchema, + complexObjectSchema, + genericObjectSchema, +]) + +const chatMessageSchema = z.object({ + role: z.string().describe('消息角色'), + content: z.union([z.string(), z.array(messageContentComplexSchema)]).describe('消息内容'), +}) + +const chatCompletionDtoSchema = z.object({ + messages: z.array(chatMessageSchema).min(1).describe('消息列表'), + model: z.string().describe('模型'), + temperature: z.number().min(0).max(2).optional().describe('温度参数'), + maxTokens: z.number().int().min(1).optional().describe('最大输出token数'), + maxCompletionTokens: z.number().optional(), + modalities: z.enum(['text', 'audio', 'image', 'video']).array().optional(), + topP: z.number().optional(), + modelKwargs: z.record(z.string(), z.any()).optional(), +}) + +export class ChatCompletionDto extends createZodDto(chatCompletionDtoSchema) {} + +const userChatCompletionDtoSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...chatCompletionDtoSchema.shape, +}) + +export class UserChatCompletionDto extends createZodDto(userChatCompletionDtoSchema) {} + +// 聊天模型查询DTO +const chatModelsQuerySchema = z.object({ + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), +}) + +export class ChatModelsQueryDto extends createZodDto(chatModelsQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.module.ts new file mode 100644 index 000000000..2d83896f8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ModelsConfigModule } from '../models-config' +import { ChatService } from './chat.service' + +@Module({ + imports: [ + ModelsConfigModule, + ], + controllers: [], + providers: [ + ChatService, + ], + exports: [ + ChatService, + ], +}) +export class ChatModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.service.ts new file mode 100644 index 000000000..a12be7f83 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.service.ts @@ -0,0 +1,175 @@ +import { BaseMessage, ChatMessage } from '@langchain/core/messages' +import { OpenAIClient } from '@langchain/openai' +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode, UserType } from '@yikart/common' +import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' +import { BigNumber } from 'bignumber.js' +import dayjs from 'dayjs' +import _ from 'lodash' +import { PointsService } from '../../../user/points.service' +import { UserService } from '../../../user/user.service' +import { OpenaiService } from '../../libs/openai' +import { ModelsConfigService } from '../models-config' +import { ChatCompletionDto, ChatModelsQueryDto, UserChatCompletionDto } from './chat.dto' + +@Injectable() +export class ChatService { + private readonly logger = new Logger(ChatService.name) + + constructor( + private readonly userService: UserService, + private readonly openaiService: OpenaiService, + private readonly pointsService: PointsService, + private readonly aiLogRepo: AiLogRepository, + private readonly modelsConfigService: ModelsConfigService, + ) {} + + async chatCompletion(request: ChatCompletionDto) { + const { messages, model, ...params } = request + + const langchainMessages: BaseMessage[] = messages.map((message) => { + return new ChatMessage(message) + }) + + const result = await this.openaiService.createChatCompletion({ + model, + messages: langchainMessages, + ...params, + modalities: params.modalities as OpenAIClient.Chat.ChatCompletionModality[], + }) + + const usage = result.usage_metadata + if (!usage) { + throw new AppException(ResponseCode.AiCallFailed) + } + + return { + model, + usage, + ...result, + } + } + + /** + * 扣减用户积分 + * @param userId 用户ID + * @param amount 扣减积分数量 + * @param description 积分变动描述 + * @param metadata 额外信息 + */ + async deductUserPoints( + userId: string, + amount: number, + description: string, + metadata?: Record, + ): Promise { + await this.pointsService.deductPoints({ + userId, + amount, + type: 'ai_service', + description, + metadata, + }) + } + + async userChatCompletion({ userId, userType, ...params }: UserChatCompletionDto) { + const modelConfig = (await this.getChatModelConfig({ userId, userType })).find((m: { name: string }) => m.name === params.model) + if (!modelConfig) { + throw new AppException(ResponseCode.InvalidModel) + } + const pricing = modelConfig.pricing + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < 0) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + if ('price' in pricing) { + const price = Number(pricing.price) + if (balance < price) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + } + } + + const startedAt = new Date() + + const result = await this.chatCompletion(params) + + const duration = Date.now() - startedAt.getTime() + + const { usage } = result + + let points = 0 + if ('price' in pricing) { + points = Number(pricing.price) + } + else { + const prompt = new BigNumber(usage.input_tokens).div('1000').times(pricing.prompt) + const completion = new BigNumber(usage.output_tokens).div('1000').times(pricing.completion) + points = prompt.plus(completion).toNumber() + } + + this.logger.debug({ + points, + usage, + modelConfig, + }) + + if (userType === UserType.User) { + await this.deductUserPoints( + userId, + points, + modelConfig.name, + usage, + ) + } + + await this.aiLogRepo.create({ + userId, + userType, + model: params.model, + channel: AiLogChannel.NewApi, + startedAt, + duration, + type: AiLogType.Chat, + points, + request: params, + response: result, + status: AiLogStatus.Success, + }) + + return { + ...result, + usage: { + ...usage, + points, + }, + } + } + + /** + * 获取聊天模型参数 + * @param data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐 + */ + async getChatModelConfig(data: ChatModelsQueryDto) { + if (data.userType === UserType.User && data.userId) { + try { + const user = await this.userService.getUserInfoById(data.userId) + if (user && user.vipInfo && dayjs(user.vipInfo.expireTime).isAfter(dayjs())) { + const models = _.cloneDeep(this.modelsConfigService.config.chat) + // 查找 gemini-2.5-flash-image 模型并直接修改价格 + const targetModel = models.find((model: { name: string }) => model.name === 'gemini-2.5-flash-image') + if (targetModel) { + targetModel.pricing = { price: '0' } + } + return models + } + } + catch (error) { + this.logger.warn({ error }) + } + } + + return this.modelsConfigService.config.chat + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.vo.ts new file mode 100644 index 000000000..a7fa26765 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/chat.vo.ts @@ -0,0 +1,65 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { messageContentComplexSchema } from './chat.dto' + +const modalitiesTokenDetails = z.object({ + text: z.number().optional(), + image: z.number().optional(), + audio: z.number().optional(), + video: z.number().optional(), + document: z.number().optional(), +}) + +const chatCompletionVoSchema = z.object({ + content: z.union([z.string(), z.array(messageContentComplexSchema)]).describe('生成内容'), + model: z.string().optional().describe('使用的模型'), + usage: z.object({ + points: z.number().optional(), + input_tokens: z.number().optional().describe('输入token数'), + output_tokens: z.number().optional().describe('输出token数'), + total_tokens: z.number().optional().describe('总token数'), + input_token_details: z.object({ + ...modalitiesTokenDetails.shape, + cache_read: z.number().optional(), + cache_creation: z.number().optional(), + }).optional(), + output_token_details: z.object({ + ...modalitiesTokenDetails.shape, + reasoning: z.number().optional(), + }).optional(), + }).optional().describe('token 使用情况'), +}) + +export class ChatCompletionVo extends createZodDto(chatCompletionVoSchema) {} + +// 对话模型参数 VO +export const chatModelSchema = z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + inputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + outputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + pricing: z.union([ + z.object({ + discount: z.string().optional(), + prompt: z.string(), + originPrompt: z.string().optional(), + completion: z.string(), + originCompletion: z.string().optional(), + image: z.string().optional(), + originImage: z.string().optional(), + audio: z.string().optional(), + originAudio: z.string().optional(), + }), + z.object({ + price: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + }), + ]), +}) + +export class ChatModelConfigVo extends createZodDto(chatModelSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/index.ts new file mode 100644 index 000000000..80d21a6bb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/chat/index.ts @@ -0,0 +1,4 @@ +export * from './chat.dto' +export * from './chat.module' +export * from './chat.service' +export * from './chat.vo' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.consumer.ts new file mode 100644 index 000000000..57e61d3a7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.consumer.ts @@ -0,0 +1,105 @@ +import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { QueueName } from '@yikart/aitoearn-queue' +import { UserType } from '@yikart/common' +import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' +import { Job } from 'bullmq' +import { FireflyCardDto, ImageEditDto, ImageGenerationDto, Md2CardDto } from './image.dto' +import { ImageService } from './image.service' + +interface AsyncTaskData { + logId: string + userId: string + userType: UserType + model: string + channel?: AiLogChannel + type: AiLogType + pricing: number + request: unknown + taskType: 'generation' | 'edit' | 'md2card' | 'fireflyCard' +} + +@Processor(QueueName.AiImageAsync, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class ImageConsumer extends WorkerHost { + private readonly logger = new Logger(ImageConsumer.name) + + constructor( + private readonly imageService: ImageService, + private readonly aiLogRepo: AiLogRepository, + ) { + super() + } + + async process(job: Job): Promise { + const { logId, userId, userType, model, pricing, request, taskType } = job.data + this.logger.log(`[log-${logId}] Processing async image task: ${taskType}`) + + const startedAt = new Date() + + try { + let result: unknown + + switch (taskType) { + case 'generation': + result = await this.imageService.generation(request as ImageGenerationDto) + break + case 'edit': + result = await this.imageService.edit(request as ImageEditDto) + break + case 'md2card': + result = await this.imageService.md2Card(request as Md2CardDto) + break + case 'fireflyCard': + result = await this.imageService.fireflyCard(request as FireflyCardDto) + break + default: + throw new Error(`Unknown task type: ${taskType}`) + } + + const duration = Date.now() - startedAt.getTime() + + // 更新日志为成功状态 + await this.aiLogRepo.updateById(logId, { + duration, + status: AiLogStatus.Success, + response: result as Record, + }) + + this.logger.log(`[log-${logId}] Task completed successfully`) + return result + } + catch (error: unknown) { + const duration = Date.now() - startedAt.getTime() + const errorMessage = error instanceof Error ? error.message : String(error) + + if (pricing > 0 && userType === UserType.User) { + await this.imageService.addUserPoints(userId, pricing, model) + } + + await this.aiLogRepo.updateById(logId, { + duration, + status: AiLogStatus.Failed, + errorMessage, + }) + + this.logger.error(`[log-${logId}] Task failed: ${errorMessage}`, error instanceof Error ? error.stack : undefined) + throw error + } + } + + @OnWorkerEvent('completed') + async onCompleted(job: Job) { + const { logId } = job.data + this.logger.log(`[log-${logId}] Job completed successfully`) + } + + @OnWorkerEvent('failed') + async onFailed(job: Job, error: Error) { + const { logId } = job.data + this.logger.error(`[log-${logId}] Job failed: ${error.message}`) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.dto.ts new file mode 100644 index 000000000..caf0f2416 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.dto.ts @@ -0,0 +1,147 @@ +import { createZodDto, UserType } from '@yikart/common' +import { z } from 'zod' +import { FireflycardTempTypes } from '../../libs/fireflycard' + +// 图片生成请求 +const imageGenerationSchema = z.object({ + prompt: z.string().min(1).max(4000).describe('图片描述提示'), + model: z.string().describe('图片生成模型'), + n: z.number().int().min(1).max(10).optional().describe('生成图片数量'), + quality: z.string().optional().describe('图片质量'), + response_format: z.enum(['url', 'b64_json']).optional().describe('返回格式'), + size: z.string().optional().describe('图片尺寸'), + style: z.string().optional().describe('图片风格'), + user: z.string().optional().describe('用户标识符'), +}) + +export class ImageGenerationDto extends createZodDto(imageGenerationSchema) {} + +// 图片编辑请求 +const imageEditSchema = z.object({ + model: z.string().describe('图片编辑模型'), + image: z.string().or(z.string().array()).describe('原始图片'), + prompt: z.string().min(1).max(4000).describe('编辑描述'), + mask: z.string().optional().describe('遮罩图片'), + n: z.int().min(1).max(1).optional().describe('生成图片数量'), + size: z.string().optional().describe('图片尺寸'), + response_format: z.enum(['url', 'b64_json']).optional().describe('返回格式'), + user: z.string().optional().describe('用户标识符'), +}) + +export class ImageEditDto extends createZodDto(imageEditSchema) {} + +// MD2Card生成请求 +const md2CardSchema = z.object({ + markdown: z.string().describe('要转换的 Markdown 文本'), + theme: z.string().default('apple-notes').optional().describe('卡片主题样式 ID'), + themeMode: z.string().optional().describe('主题的模式 ID'), + width: z.int().min(100).max(2000).default(440).optional().describe('卡片宽度(像素)'), + height: z.int().min(100).max(3000).default(586).optional().describe('卡片高度(像素)'), + splitMode: z.string().default('noSplit').optional().describe('分割模式'), + mdxMode: z.boolean().default(false).optional().describe('是否启用 MDX 模式'), + overHiddenMode: z.boolean().default(false).optional().describe('是否启用溢出隐藏模式'), +}) + +export class Md2CardDto extends createZodDto(md2CardSchema) {} + +// Fireflycard模板类型枚举 +const fireflycardTempSchema = z.enum(FireflycardTempTypes) + +// Fireflycard样式配置 +const fireflycardStyleSchema = z.object({ + align: z.string().optional().describe('对齐方式'), + backgroundName: z.string().optional().describe('背景名称'), + backShadow: z.string().optional().describe('背景阴影'), + font: z.string().optional().describe('字体'), + width: z.number().optional().describe('宽度'), + ratio: z.string().optional().describe('比例'), + height: z.number().optional().describe('高度'), + fontScale: z.number().optional().describe('字体缩放'), + padding: z.string().optional().describe('内边距'), + borderRadius: z.string().optional().describe('边框圆角'), + color: z.string().optional().describe('颜色'), + opacity: z.number().optional().describe('透明度'), + blur: z.number().optional().describe('模糊度'), + backgroundAngle: z.string().optional().describe('背景角度'), + lineHeights: z.object({ + content: z.string().optional().describe('内容行高'), + }).optional().describe('行高设置'), + letterSpacings: z.object({ + content: z.string().optional().describe('内容字间距'), + }).optional().describe('字间距设置'), +}).optional() + +// Fireflycard开关配置 +const fireflycardSwitchConfigSchema = z.object({ + showIcon: z.boolean().optional().describe('显示图标'), + showDate: z.boolean().optional().describe('显示日期'), + showTitle: z.boolean().optional().describe('显示标题'), + showContent: z.boolean().optional().describe('显示内容'), + showAuthor: z.boolean().optional().describe('显示作者'), + showTextCount: z.boolean().optional().describe('显示文字计数'), + showQRCode: z.boolean().optional().describe('显示二维码'), + showPageNum: z.boolean().optional().describe('显示页码'), + showWatermark: z.boolean().optional().describe('显示水印'), +}).optional() + +// Fireflycard生成请求 +const fireflyCardSchema = z.object({ + content: z.string().min(1).describe('卡片内容'), + temp: fireflycardTempSchema.default(FireflycardTempTypes.A).describe('模板类型'), + title: z.string().optional().describe('标题'), + style: fireflycardStyleSchema.describe('样式配置'), + switchConfig: fireflycardSwitchConfigSchema.describe('开关配置'), +}) + +export class FireflyCardDto extends createZodDto(fireflyCardSchema) {} + +// 用户图片生成请求 +const userImageGenerationSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...imageGenerationSchema.shape, +}) + +export class UserImageGenerationDto extends createZodDto(userImageGenerationSchema) {} + +// 用户图片编辑请求 +const userImageEditSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...imageEditSchema.shape, +}) + +export class UserImageEditDto extends createZodDto(userImageEditSchema) {} + +// 用户Md2Card +const userMd2CardSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...md2CardSchema.shape, +}) +export class UserMd2CardDto extends createZodDto(userMd2CardSchema) {} + +// Fireflycard生成请求 +const userFireflyCardSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...fireflyCardSchema.shape, +}) + +export class UserFireflyCardDto extends createZodDto(userFireflyCardSchema) {} + +// 图片生成模型查询DTO +const imageGenerationModelsQuerySchema = z.object({ + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), +}) + +export class ImageGenerationModelsQueryDto extends createZodDto(imageGenerationModelsQuerySchema) {} + +// 图片编辑模型查询DTO +const imageEditModelsQuerySchema = z.object({ + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), +}) + +export class ImageEditModelsQueryDto extends createZodDto(imageEditModelsQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.module.ts new file mode 100644 index 000000000..c5bfc5400 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { config } from '../../../config' +import { FireflycardModule } from '../../libs/fireflycard' +import { Md2cardModule } from '../../libs/md2card' +import { ModelsConfigModule } from '../models-config' +import { ImageConsumer } from './image.consumer' +import { ImageService } from './image.service' + +@Module({ + imports: [ + FireflycardModule.forRoot(config.ai.fireflycard), + Md2cardModule.forRoot(config.ai.md2card), + ModelsConfigModule, + ], + controllers: [], + providers: [ImageService, ImageConsumer], + exports: [ImageService], +}) +export class ImageModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.service.ts new file mode 100644 index 000000000..31e288a4a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.service.ts @@ -0,0 +1,632 @@ +import path from 'node:path' +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common' +import { QueueService } from '@yikart/aitoearn-queue' +import { S3Service } from '@yikart/aws-s3' +import { AppException, getExtByMimeType, ImageType, ResponseCode, UserType } from '@yikart/common' +import { AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' +import parseDataUri from 'data-urls' +import OpenAI from 'openai' +import { PointsService } from '../../../user/points.service' +import { FireflycardService } from '../../libs/fireflycard' +import { Md2cardService } from '../../libs/md2card' +import { OpenaiService } from '../../libs/openai' +import { ModelsConfigService } from '../models-config' +import { + FireflyCardDto, + ImageEditDto, + ImageEditModelsQueryDto, + ImageGenerationDto, + ImageGenerationModelsQueryDto, + Md2CardDto, + UserFireflyCardDto, + UserImageEditDto, + UserImageGenerationDto, + UserMd2CardDto, +} from './image.dto' + +type Uploadable = File | Response + +@Injectable() +export class ImageService { + private readonly logger = new Logger(ImageService.name) + + constructor( + private readonly fireflyCardService: FireflycardService, + private readonly s3Service: S3Service, + private readonly openaiService: OpenaiService, + private readonly md2cardService: Md2cardService, + private readonly aiLogRepo: AiLogRepository, + private readonly pointsService: PointsService, + private readonly modelsConfigService: ModelsConfigService, + private readonly queueService: QueueService, + ) { } + + /** + * 将 data uri 转换为 Uploadable + */ + private getUploadableByDataUri(dataUri: string, filename = 'image'): Uploadable { + const file = parseDataUri(dataUri) + if (file == null) { + throw new BadRequestException('Invalid data URI') + } + const ext = getExtByMimeType(file.mimeType.essence as ImageType) + + return new File([file.body as BlobPart], `${filename}.${ext}`, { type: file.mimeType.essence }) + } + + /** + * 将 URL 转换为 Uploadable + */ + private async getUploadableByUrl(url: string): Promise { + return await fetch(url) + } + + /** + * 将 URL 或 Data URI 转换为 Uploadable + */ + private async getUploadableByUrlOrDataUri(urlOrDataUri: string, filename = 'image'): Promise { + if (/^https?:\/\//.test(urlOrDataUri)) { + return await this.getUploadableByUrl(urlOrDataUri) + } + return this.getUploadableByDataUri(urlOrDataUri, filename) + } + + /** + * 上传图片到S3并返回路径 + */ + private async uploadImageToS3(imageUrlOrResponse: string | Response, basePath: string, user?: string): Promise { + if (typeof imageUrlOrResponse === 'string') { + const filename = `${Date.now().toString(36)}-${path.basename(imageUrlOrResponse.split('?')[0])}` + const fullPath = path.join(basePath, user || '', filename) + const result = await this.s3Service.putObjectFromUrl(imageUrlOrResponse, fullPath) + return result.path + } + else { + const contentType = imageUrlOrResponse.headers.get('content-type')! + const ext = getExtByMimeType(contentType as ImageType) + const filename = `${Date.now().toString(36)}.${ext}` + const fullPath = path.join(basePath, user || '', filename) + const result = await this.s3Service.putObject(fullPath, imageUrlOrResponse.body!) + return result.path + } + } + + /** + * 图片生成 + */ + async generation(request: ImageGenerationDto) { + const { user, ...params } = request + + if (params.model === 'gpt-image-1') { + delete params.response_format + delete params.style + } + + const result = await this.openaiService.createImageGeneration({ + ...params, + } as Omit & { apiKey?: string }) + + for (const image of result.data || []) { + if (image.url) { + image.url = await this.uploadImageToS3(image.url, `ai/images/${request.model}`, user) + } + if (image.b64_json) { + const fullPath = path.join(`ai/images/${request.model}`, user || '', `${Date.now().toString(36)}.${result.output_format || 'png'}`) + const obj = await this.s3Service.putObject(fullPath, Buffer.from(image.b64_json, 'base64')) + image.url = obj.path + delete image.b64_json + } + } + + return { + ...result, + list: result.data || [], + } + } + + /** + * 图片编辑 + */ + async edit(request: ImageEditDto) { + const { image, mask, user, ...params } = request + + let imageFile: Uploadable | Uploadable[] + if (Array.isArray(image)) { + imageFile = await Promise.all(image.map((img, index) => + this.getUploadableByUrlOrDataUri(img, `image-${index}`), + )) + } + else { + imageFile = await this.getUploadableByUrlOrDataUri(image, 'image') + } + + const maskFile = mask ? await this.getUploadableByUrlOrDataUri(mask, 'mask') : undefined + + if (params.model === 'gpt-image-1') { + delete params.response_format + } + const imageResult = await this.openaiService.createImageEdit({ + ...params, + image: imageFile, + mask: maskFile, + size: params.size as 'auto', + }) + + for (const image of imageResult.data || []) { + if (image.url) { + image.url = await this.uploadImageToS3(image.url, `ai/images/${request.model}`, user) + } + if (image.b64_json) { + const fullPath = path.join(`ai/images/${request.model}`, user || '', `${Date.now().toString(36)}.png`) + const result = await this.s3Service.putObject(fullPath, Buffer.from(image.b64_json, 'base64')) + image.url = result.path + delete image.b64_json + } + } + + return { + created: imageResult.created, + list: imageResult.data || [], + usage: imageResult.usage, + } + } + + /** + * MD2Card生成 + */ + async md2Card(request: Md2CardDto) { + const result = await this.md2cardService.generateCard(request) + + for (const image of result.images) { + image.url = await this.uploadImageToS3(image.url, 'ai/images/md2card') + } + + return result + } + + /** + * Fireflycard生成 + */ + async fireflyCard(request: FireflyCardDto) { + const reponse = await this.fireflyCardService.createImage(request) + + const imagePath = await this.uploadImageToS3(reponse, 'ai/images/md2card') + return { + image: imagePath, + } + } + + /** + * 扣减用户积分 + */ + private async deductUserPoints( + userId: string, + amount: number, + description: string, + metadata?: Record, + ): Promise { + await this.pointsService.deductPoints({ + userId, + amount, + type: 'ai_service', + description, + metadata, + }) + } + + /** + * 恢复用户积分 + */ + async addUserPoints( + userId: string, + amount: number, + description: string, + metadata?: Record, + ): Promise { + await this.pointsService.addPoints({ + userId, + amount, + type: 'ai_service', + description, + metadata, + }) + } + + /** + * 获取图片模型价格 + */ + private async getImageModelPricing(model: string, kind: 'generation' | 'edit', userId?: string, userType?: UserType): Promise { + const list = kind === 'generation' ? await this.generationModelConfig({ userId, userType }) : await this.editModelConfig({ userId, userType }) + const modelConfig = list.find(m => m.name === model) + if (!modelConfig) { + throw new AppException(ResponseCode.InvalidModel, 'model not found') + } + return Number(modelConfig.pricing) + } + + /** + * 统一的用户请求处理:校验余额、计费、扣费、日志 + */ + private async handleUserAiAction(opts: { + userId: string + userType: UserType + model: string + channel?: AiLogChannel + type: AiLogType + pricing: number + request: Record + run: () => Promise + }): Promise { + const { userId, userType, model, channel, type, pricing, request, run } = opts + const startedAt = new Date() + + const log = await this.aiLogRepo.create({ + userId, + userType, + model, + channel: channel ?? AiLogChannel.NewApi, + type, + points: pricing, + request, + status: AiLogStatus.Generating, + startedAt, + }) + + if (pricing > 0 && userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.deductUserPoints(userId, pricing, model) + } + + const result = await run().catch(async (e) => { + if (pricing > 0 && userType === UserType.User) { + await this.addUserPoints(userId, pricing, model) + } + const duration = Date.now() - startedAt.getTime() + + await this.aiLogRepo.updateById(log.id, { + duration, + status: AiLogStatus.Failed, + errorMessage: e.message, + }) + throw e + }) + const duration = Date.now() - startedAt.getTime() + + await this.aiLogRepo.updateById(log.id, { + duration, + status: AiLogStatus.Success, + response: result as Record, + }) + + return result + } + + /** + * 用户图片生成 + */ + async userGeneration(request: UserImageGenerationDto) { + const { userId, userType, ...params } = request + + const pricing = await this.getImageModelPricing(params.model, 'generation') + + return await this.handleUserAiAction({ + userId, + userType, + model: params.model, + type: AiLogType.Image, + pricing, + request: params, + run: () => this.generation({ ...params, user: userId }), + }) + } + + /** + * 用户图片编辑 + */ + async userEdit(request: UserImageEditDto) { + const { userId, userType, ...params } = request + + const pricing = await this.getImageModelPricing(params.model, 'edit') + + return await this.handleUserAiAction({ + userId, + userType, + model: params.model, + type: AiLogType.Image, + pricing, + request: params, + run: () => this.edit({ ...params, user: userId }), + }) + } + + async userMd2Card(request: UserMd2CardDto) { + const { userId, userType, ...params } = request + const pricing = 2 + + return await this.handleUserAiAction({ + userId, + userType, + model: 'md2card', + channel: AiLogChannel.Md2Card, + type: AiLogType.Card, + pricing, + request: params, + run: () => this.md2Card(params), + }) + } + + async userFireFlyCard(request: UserFireflyCardDto) { + const { userId, userType, ...params } = request + + return await this.handleUserAiAction({ + userId, + userType, + model: 'fireflyCard', + channel: AiLogChannel.FireflyCard, + type: AiLogType.Card, + pricing: 0, + request: params, + run: () => this.fireflyCard(params), + }) + } + + /** + * 获取图片生成模型参数 + */ + /** + * 获取图片生成模型参数 + * @param _data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐 + */ + async generationModelConfig(_data: ImageGenerationModelsQueryDto) { + // 目前返回所有模型,后续可根据 userId 和 userType 进行个性化过滤 + return this.modelsConfigService.config.image.generation + } + + /** + * 获取图片编辑模型参数 + * @param _data 查询参数,包含可选的 userId 和 userType,可用于后续个性化模型推荐 + */ + async editModelConfig(_data: ImageEditModelsQueryDto) { + return this.modelsConfigService.config.image.edit + } + + /** + * 异步图片生成 + */ + async userGenerationAsync(request: UserImageGenerationDto) { + const { userId, userType, ...params } = request + const pricing = await this.getImageModelPricing(params.model, 'generation', userId, userType) + + // 创建 AiLog 记录 + const log = await this.aiLogRepo.create({ + userId, + userType, + model: params.model, + channel: AiLogChannel.NewApi, + type: AiLogType.Image, + points: pricing, + request: params, + status: AiLogStatus.Generating, + startedAt: new Date(), + }) + + // 扣除积分 + if (pricing > 0 && userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + await this.aiLogRepo.updateById(log.id, { + status: AiLogStatus.Failed, + errorMessage: '积分不足', + }) + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.deductUserPoints(userId, pricing, params.model) + } + + // 添加队列任务 + await this.queueService.addAiImageAsyncJob({ + logId: log.id, + userId, + userType, + model: params.model, + channel: AiLogChannel.NewApi, + type: AiLogType.Image, + pricing, + request: { ...params, user: userId }, + taskType: 'generation', + }) + + return { + logId: log.id, + status: AiLogStatus.Generating, + } + } + + /** + * 异步图片编辑 + */ + async userEditAsync(request: UserImageEditDto) { + const { userId, userType, ...params } = request + const pricing = await this.getImageModelPricing(params.model, 'edit', userId, userType) + + // 创建 AiLog 记录 + const log = await this.aiLogRepo.create({ + userId, + userType, + model: params.model, + channel: AiLogChannel.NewApi, + type: AiLogType.Image, + points: pricing, + request: params, + status: AiLogStatus.Generating, + startedAt: new Date(), + }) + + // 扣除积分 + if (pricing > 0 && userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + await this.aiLogRepo.updateById(log.id, { + status: AiLogStatus.Failed, + errorMessage: '积分不足', + }) + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.deductUserPoints(userId, pricing, params.model) + } + + // 添加队列任务 + await this.queueService.addAiImageAsyncJob({ + logId: log.id, + userId, + userType, + model: params.model, + channel: AiLogChannel.NewApi, + type: AiLogType.Image, + pricing, + request: { ...params, user: userId }, + taskType: 'edit', + }) + + return { + logId: log.id, + status: AiLogStatus.Generating, + } + } + + /** + * 异步 MD2Card 生成 + */ + async userMd2CardAsync(request: UserMd2CardDto) { + const { userId, userType, ...params } = request + const pricing = 2 + + // 创建 AiLog 记录 + const log = await this.aiLogRepo.create({ + userId, + userType, + model: 'md2card', + channel: AiLogChannel.Md2Card, + type: AiLogType.Card, + points: pricing, + request: params, + status: AiLogStatus.Generating, + startedAt: new Date(), + }) + + // 扣除积分 + if (pricing > 0 && userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + await this.aiLogRepo.updateById(log.id, { + status: AiLogStatus.Failed, + errorMessage: '积分不足', + }) + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.deductUserPoints(userId, pricing, 'md2card') + } + + // 添加队列任务 + await this.queueService.addAiImageAsyncJob({ + logId: log.id, + userId, + userType, + model: 'md2card', + channel: AiLogChannel.Md2Card, + type: AiLogType.Card, + pricing, + request: params, + taskType: 'md2card', + }) + + return { + logId: log.id, + status: AiLogStatus.Generating, + } + } + + /** + * 异步 FireflyCard 生成 + */ + async userFireFlyCardAsync(request: UserFireflyCardDto) { + const { userId, userType, ...params } = request + const pricing = 0 + + // 创建 AiLog 记录 + const log = await this.aiLogRepo.create({ + userId, + userType, + model: 'fireflyCard', + channel: AiLogChannel.FireflyCard, + type: AiLogType.Card, + points: pricing, + request: params, + status: AiLogStatus.Generating, + startedAt: new Date(), + }) + + // 添加队列任务 + await this.queueService.addAiImageAsyncJob({ + logId: log.id, + userId, + userType, + model: 'fireflyCard', + channel: AiLogChannel.FireflyCard, + type: AiLogType.Card, + pricing, + request: params, + taskType: 'fireflyCard', + }) + + return { + logId: log.id, + status: AiLogStatus.Generating, + } + } + + /** + * 查询任务状态 + */ + async getTaskStatus(logId: string) { + const log = await this.aiLogRepo.getById(logId) + if (!log) { + throw new NotFoundException('任务不存在') + } + + // 提取图片信息 + let images: Array<{ url?: string, b64_json?: string, revised_prompt?: string }> | undefined + if (log.response) { + // 处理不同的响应格式 + if (log.response['list'] && Array.isArray(log.response['list'])) { + // 图片生成和编辑的响应格式 + images = log.response['list'] as Array<{ url?: string, b64_json?: string, revised_prompt?: string }> + } + else if (log.response['images'] && Array.isArray(log.response['images'])) { + // MD2Card 的响应格式 + images = log.response['images'] as Array<{ url?: string, b64_json?: string, revised_prompt?: string }> + } + else if (log.response['image']) { + // FireflyCard 的响应格式 + images = [{ url: log.response['image'] as string }] + } + } + + return { + logId: log.id, + status: log.status, + startedAt: log.startedAt, + duration: log.duration, + points: log.points, + request: log.request, + response: log.response, + images, + errorMessage: log.errorMessage, + createdAt: log.createdAt, + updatedAt: log.updatedAt, + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.vo.ts new file mode 100644 index 000000000..8366a8fcc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/image.vo.ts @@ -0,0 +1,107 @@ +import { createZodDto } from '@yikart/common' +import { AiLogStatus } from '@yikart/mongodb' +import { z } from 'zod' + +// 使用情况统计 +const usageMetadataSchema = z.object({ + input_tokens: z.number().optional().describe('输入token数'), + output_tokens: z.number().optional().describe('输出token数'), + total_tokens: z.number().optional().describe('总token数'), +}) + +// 图片对象 +const imageObjectSchema = z.object({ + url: z.string().optional().describe('图片URL'), + b64_json: z.string().optional().describe('base64编码的图片'), + revised_prompt: z.string().optional().describe('修订后的提示词'), +}) + +// 用户图片响应 +const userImageResponseSchema = z.object({ + created: z.number().describe('创建时间戳'), + list: z.array(imageObjectSchema).describe('生成的图片列表'), + usage: usageMetadataSchema.optional().describe('token使用情况'), + background: z.string().optional(), + output_format: z.string().optional(), + quality: z.string().optional(), + size: z.string().optional(), +}) + +export class ImageResponseVo extends createZodDto(userImageResponseSchema) {} + +// 图片生成模型参数 VO +const imageGenerationModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()).describe('支持的尺寸'), + qualities: z.array(z.string()).describe('支持的质量选项'), + styles: z.array(z.string()).describe('支持的风格选项'), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), +}) + +export class ImageGenerationModelParamsVo extends createZodDto(imageGenerationModelSchema) {} + +// 图片编辑模型参数 VO +const imageEditModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()).describe('支持的尺寸'), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + maxInputImages: z.number(), +}) + +export class ImageEditModelParamsVo extends createZodDto(imageEditModelSchema) {} + +// MD2Card生成响应 +const md2CardResponseSchema = z.object({ + images: z.array(z.object({ + url: z.string().describe('图片URL'), + fileName: z.string().describe('文件名'), + })).describe('生成的卡片图片'), +}) + +export class Md2CardResponseVo extends createZodDto(md2CardResponseSchema) {} + +// Fireflycard生成响应 +const fireflycardResponseSchema = z.object({ + image: z.string().describe('生成的卡片图片base64数据'), +}) + +export class FireflycardResponseVo extends createZodDto(fireflycardResponseSchema) {} + +// 异步任务响应 +const asyncTaskResponseSchema = z.object({ + logId: z.string().describe('任务日志ID'), + status: z.enum(AiLogStatus).describe('任务状态'), +}) + +export class AsyncTaskResponseVo extends createZodDto(asyncTaskResponseSchema) {} + +// 任务状态响应 +const taskStatusResponseSchema = z.object({ + logId: z.string().describe('任务日志ID'), + status: z.enum(AiLogStatus).describe('任务状态'), + startedAt: z.date().describe('开始时间'), + duration: z.number().optional().describe('持续时间(毫秒)'), + points: z.number().describe('消耗积分'), + request: z.record(z.string(), z.unknown()).describe('请求参数'), + response: z.record(z.string(), z.unknown()).optional().describe('响应结果'), + images: z.array(imageObjectSchema).optional().describe('生成的图片列表'), + errorMessage: z.string().optional().describe('错误信息'), + createdAt: z.date().describe('创建时间'), + updatedAt: z.date().describe('更新时间'), +}) + +export class TaskStatusResponseVo extends createZodDto(taskStatusResponseSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/index.ts new file mode 100644 index 000000000..6ed73532e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/image/index.ts @@ -0,0 +1,4 @@ +export * from './image.dto' +export * from './image.module' +export * from './image.service' +export * from './image.vo' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/index.ts new file mode 100644 index 000000000..2e36c9b89 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/index.ts @@ -0,0 +1,4 @@ +export * from './logs.dto' +export * from './logs.module' +export * from './logs.service' +export * from './logs.vo' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.dto.ts new file mode 100644 index 000000000..489c489cc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.dto.ts @@ -0,0 +1,20 @@ +import { createZodDto, PaginationDtoSchema, UserType } from '@yikart/common' +import { z } from 'zod' + +// 日志列表查询参数 +export const logListQuerySchema = z.object({ + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), + ...PaginationDtoSchema.shape, +}) + +export class LogListQueryDto extends createZodDto(logListQuerySchema) {} + +// 日志详情查询请求 +const logDetailQuerySchema = z.object({ + id: z.string().min(1).describe('日志ID'), + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), +}) + +export class LogDetailQueryDto extends createZodDto(logDetailQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.module.ts new file mode 100644 index 000000000..22493ae1e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common' +import { LogsService } from './logs.service' + +@Global() +@Module({ + controllers: [], + providers: [LogsService], + exports: [LogsService], +}) +export class LogsModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.service.ts new file mode 100644 index 000000000..c6fab794c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { AiLogRepository } from '@yikart/mongodb' +import { LogDetailQueryDto, LogListQueryDto } from './logs.dto' + +@Injectable() +export class LogsService { + constructor( + private readonly aiLogRepo: AiLogRepository, + ) {} + + /** + * 查询日志列表 + */ + async getLogList(query: LogListQueryDto) { + return await this.aiLogRepo.listWithPagination(query) + } + + /** + * 查询日志详情 + */ + async getLogDetail({ id, userId, userType }: LogDetailQueryDto) { + let log + if (userId && userType) { + log = await this.aiLogRepo.getByIdAndUserId(id, userId, userType) + } + else { + log = await this.aiLogRepo.getById(id) + } + + if (!log) { + throw new AppException(ResponseCode.AiLogNotFound) + } + + return log + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.vo.ts new file mode 100644 index 000000000..e9d807bfe --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/logs/logs.vo.ts @@ -0,0 +1,36 @@ +import { createPaginationVo, createZodDto, UserType } from '@yikart/common' +import { AiLogChannel, AiLogStatus, AiLogType } from '@yikart/mongodb' +import { z } from 'zod' + +// 日志基本信息 +const logItemSchema = z.object({ + id: z.string().describe('日志ID'), + userId: z.string().describe('用户ID'), + userType: z.enum(UserType).describe('用户类型'), + taskId: z.string().optional().describe('任务ID'), + type: z.enum(AiLogType).describe('日志类型'), + model: z.string().describe('模型'), + channel: z.enum(AiLogChannel).describe('渠道'), + action: z.string().optional().describe('操作'), + status: z.enum(AiLogStatus).describe('日志状态'), + startedAt: z.date().describe('开始时间'), + duration: z.number().optional().describe('持续时间'), + points: z.number().describe('积分'), + createdAt: z.date().describe('创建时间'), + updatedAt: z.date().describe('更新时间'), +}) + +export class LogItemVo extends createZodDto(logItemSchema) {} + +// 日志列表响应 +export class LogsListResponseVo extends createPaginationVo(logItemSchema, 'LogsListResponseVo') {} + +// 日志详情响应 +const logDetailResponseSchema = z.object({ + ...logItemSchema.shape, + request: z.record(z.string(), z.unknown()).describe('请求参数'), + response: z.record(z.string(), z.unknown()).optional().describe('响应结果'), + errorMessage: z.string().optional().describe('错误信息'), +}) + +export class LogDetailResponseVo extends createZodDto(logDetailResponseSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/index.ts new file mode 100644 index 000000000..707bbc0f3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/index.ts @@ -0,0 +1,4 @@ +export * from './models-config.dto' +export * from './models-config.module' +export * from './models-config.service' +export * from './models-config.vo' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.dto.ts new file mode 100644 index 000000000..b59bc3463 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.dto.ts @@ -0,0 +1,4 @@ +import { createZodDto } from '@yikart/common' +import { aiModelsConfigSchema } from '../../../config' + +export class ModelsConfigDto extends createZodDto(aiModelsConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.module.ts new file mode 100644 index 000000000..9c5ca4bdd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { ModelsConfigService } from './models-config.service' + +@Module({ + imports: [ + ], + controllers: [], + providers: [ + ModelsConfigService, + ], + exports: [ + ModelsConfigService, + ], +}) +export class ModelsConfigModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.service.ts new file mode 100644 index 000000000..f1dacac07 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common' +import { AppConfigRepository } from '@yikart/mongodb' +import { config } from '../../../config' +import { ModelsConfigDto } from './models-config.dto' + +@Injectable() +export class ModelsConfigService implements OnModuleInit { + private readonly logger = new Logger(ModelsConfigService.name) + private modelsConfig = config.ai.models + + constructor( + private readonly appConfigRepo: AppConfigRepository, + ) {} + + async onModuleInit() { + const [config] = await this.appConfigRepo.listByAppIdAndKey('aitoearn-ai', 'models') + if (config == null) { + return + } + this.modelsConfig = config.value as ModelsConfigDto + } + + async saveConfig(config: ModelsConfigDto) { + await this.appConfigRepo.upsertByAppIdAndKey('aitoearn-ai', 'models', { + appId: 'aitoearn-ai', + key: 'models', + value: config, + }) + this.modelsConfig = config + } + + get config() { + return this.modelsConfig + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.vo.ts new file mode 100644 index 000000000..510d82c69 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/models-config/models-config.vo.ts @@ -0,0 +1,4 @@ +import { createZodDto } from '@yikart/common' +import { aiModelsConfigSchema } from '../../../config' + +export class ModelsConfigVo extends createZodDto(aiModelsConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/index.ts new file mode 100644 index 000000000..b2517cc4b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/index.ts @@ -0,0 +1,4 @@ +export * from './video.dto' +export * from './video.module' +export * from './video.service' +export * from './video.vo' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.dto.ts new file mode 100644 index 000000000..9cf0fbefc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.dto.ts @@ -0,0 +1,358 @@ +import { createZodDto, PaginationDtoSchema, UserType } from '@yikart/common' +import { z } from 'zod' +import { AspectRatio, CameraControlType, TaskStatus as KlingTaskStatus, Mode } from '../../libs/kling' +import { TaskStatus as Sora2TaskStatus, VideoOrientation, VideoSize } from '../../libs/sora2' +import { ContentType, ImageRole, TaskStatus } from '../../libs/volcengine' +// 移除了不必要的类型导入,因为现在使用zod schema + +// 通用视频生成请求 +const videoGenerationRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + prompt: z.string().min(1).max(4000).describe('提示词'), + image: z.string().or(z.string().array()).optional().describe('图片URL或base64'), + image_tail: z.string().optional().describe('尾帧图片URL或base64'), + mode: z.string().optional().describe('生成模式'), + size: z.string().optional().describe('尺寸'), + duration: z.number().optional().describe('时长'), + metadata: z.record(z.string(), z.unknown()).optional().describe('其他参数'), +}) + +export class VideoGenerationRequestDto extends createZodDto(videoGenerationRequestSchema) {} + +// 通用视频任务状态查询 +const videoTaskQuerySchema = z.object({ + taskId: z.string().min(1).describe('任务ID'), +}) + +export class VideoTaskQueryDto extends createZodDto(videoTaskQuerySchema) {} + +// 通用视频生成请求 +const userVideoGenerationRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...videoGenerationRequestSchema.shape, +}) + +export class UserVideoGenerationRequestDto extends createZodDto(userVideoGenerationRequestSchema) {} + +// 通用视频任务状态查询 +const userVideoTaskQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...videoTaskQuerySchema.shape, +}) + +export class UserVideoTaskQueryDto extends createZodDto(userVideoTaskQuerySchema) {} + +// 通用视频任务状态查询 +const listUserVideoTasksQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + ...PaginationDtoSchema.shape, +}) + +export class UserListVideoTasksQueryDto extends createZodDto(listUserVideoTasksQuerySchema) {} + +// Kling文生视频请求 +const klingText2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model_name: z.string().min(1).describe('模型名称'), + prompt: z.string().min(1).max(2500).describe('正向文本提示词'), + negative_prompt: z.string().max(2500).optional().describe('负向文本提示词'), + cfg_scale: z.number().min(0).max(1).optional().describe('生成视频的自由度'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), + external_task_id: z.string().optional().describe('自定义任务ID'), +}) + +export class KlingText2VideoRequestDto extends createZodDto(klingText2VideoRequestSchema) {} + +// Volcengine视频生成请求 +const volcengineGenerationRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model: z.string().describe('模型ID或Endpoint ID'), + content: z.array(z.union([ + z.object({ + type: z.literal(ContentType.Text), + text: z.string(), + }), + z.object({ + type: z.literal(ContentType.ImageUrl), + image_url: z.object({ + url: z.string(), + }), + role: z.enum(ImageRole).optional(), + }), + ])).describe('输入内容'), + return_last_frame: z.boolean().optional().describe('是否返回尾帧图像'), +}) + +export class VolcengineGenerationRequestDto extends createZodDto(volcengineGenerationRequestSchema) {} + +// Kling回调接口DTO +const klingCallbackSchema = z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.enum(KlingTaskStatus).describe('任务状态'), + task_status_msg: z.string().describe('任务状态信息'), + task_info: z.object({ + parent_video: z.object({ + id: z.string().describe('续写前的视频ID'), + url: z.string().describe('续写前视频的URL'), + duration: z.string().describe('续写前的视频总时长,单位s'), + }).optional(), + external_task_id: z.string().optional().describe('客户自定义任务ID'), + }).describe('任务创建时的参数信息'), + created_at: z.number().describe('任务创建时间,Unix时间戳、单位ms'), + updated_at: z.number().describe('任务更新时间,Unix时间戳、单位ms'), + task_result: z.object({ + images: z.array(z.object({ + index: z.number().describe('图片编号'), + url: z.string().describe('生成图片的URL'), + })).optional().describe('图片类任务的结果'), + videos: z.array(z.object({ + id: z.string().describe('视频ID'), + url: z.string().describe('视频的URL'), + duration: z.string().describe('视频总时长,单位s'), + })).optional().describe('视频类任务的结果'), + }).optional().describe('任务结果'), +}) + +export class KlingCallbackDto extends createZodDto(klingCallbackSchema) {} + +// Volcengine回调接口DTO(与查询API返回格式一致) +const volcengineCallbackSchema = z.object({ + id: z.string(), + model: z.string(), + status: z.enum(TaskStatus), + created_at: z.number(), + updated_at: z.number(), + content: z.object({ + video_url: z.string(), + last_frame_url: z.string().optional(), + }).optional(), + error: z.object({ + message: z.string(), + code: z.string(), + }).optional().nullable(), + seed: z.number().optional(), + resolution: z.string().optional(), + ratio: z.string().optional(), + duration: z.number().optional(), + framespersecond: z.number().optional(), + usage: z.object({ + completion_tokens: z.number(), + total_tokens: z.number(), + }).optional(), +}) + +export class VolcengineCallbackDto extends createZodDto(volcengineCallbackSchema) {} + +// ==================== Kling API 其他接口 DTO ==================== + +// 图生视频请求DTO +const klingImage2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model_name: z.string().min(1).describe('模型名称'), + image: z.string().optional().describe('参考图像'), + image_tail: z.string().optional().describe('参考图像 - 尾帧控制'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + cfg_scale: z.number().optional().describe('生成视频的自由度'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + static_mask: z.string().optional().describe('静态笔刷涂抹区域'), + dynamic_masks: z.array(z.object({ + mask: z.string().optional(), + trajectories: z.array(z.object({ + x: z.number().optional(), + y: z.number().optional(), + })), + })).optional().describe('动态笔刷配置列表'), + camera_control: z.object({ + type: z.enum(CameraControlType).optional(), + config: z.object({ + horizontal: z.number().optional(), + vertical: z.number().optional(), + pan: z.number().optional(), + tilt: z.number().optional(), + roll: z.number().optional(), + zoom: z.number().optional(), + }).optional(), + }).optional().describe('控制摄像机运动的协议'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), +}) + +export class KlingImage2VideoRequestDto extends createZodDto(klingImage2VideoRequestSchema) {} + +// 多图生视频请求DTO +const klingMultiImage2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model_name: z.string().min(1).describe('模型名称'), + image_list: z.array(z.object({ + image: z.string(), + })).describe('图片列表'), + prompt: z.string().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), + aspect_ratio: z.enum(AspectRatio).optional().describe('生成图片的画面纵横比'), +}) + +export class KlingMultiImage2VideoRequestDto extends createZodDto(klingMultiImage2VideoRequestSchema) {} + +// Kling任务查询DTO +const klingTaskQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + taskId: z.string().min(1).describe('任务ID'), +}) + +export class KlingTaskQueryDto extends createZodDto(klingTaskQuerySchema) {} + +// Volcengine任务查询DTO +const volcengineTaskQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + taskId: z.string().min(1).describe('任务ID'), +}) + +export class VolcengineTaskQueryDto extends createZodDto(volcengineTaskQuerySchema) {} + +// ==================== Dashscope API DTO ==================== + +// Dashscope文生视频请求DTO +const dashscopeText2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model: z.string().min(1).describe('模型名称'), + input: z.object({ + prompt: z.string().min(1).describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + }), + parameters: z.object({ + size: z.string().optional().describe('视频尺寸'), + duration: z.number().optional().describe('视频时长(秒)'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeText2VideoRequestDto extends createZodDto(dashscopeText2VideoRequestSchema) {} + +// Dashscope图生视频请求DTO +const dashscopeImage2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model: z.string().min(1).describe('模型名称'), + input: z.object({ + image_url: z.string().min(1).describe('图片URL'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + }), + parameters: z.object({ + resolution: z.string().optional().describe('分辨率'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeImage2VideoRequestDto extends createZodDto(dashscopeImage2VideoRequestSchema) {} + +// Dashscope首尾帧生视频请求DTO +const dashscopeKeyFrame2VideoRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model: z.string().min(1).describe('模型名称'), + input: z.object({ + first_frame_url: z.string().min(1).describe('首帧图片URL'), + last_frame_url: z.string().optional().describe('尾帧图片URL'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + template: z.string().optional().describe('模板'), + }), + parameters: z.object({ + resolution: z.string().optional().describe('分辨率'), + duration: z.number().optional().describe('视频时长(秒)'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeKeyFrame2VideoRequestDto extends createZodDto(dashscopeKeyFrame2VideoRequestSchema) {} + +// Dashscope回调DTO +const dashscopeCallbackSchema = z.object({ + status_code: z.number().describe('HTTP状态码'), + request_id: z.string().describe('请求ID'), + code: z.string().nullable().describe('错误码'), + message: z.string().describe('错误消息'), + output: z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.string().describe('任务状态'), + video_url: z.string().optional().describe('视频URL'), + submit_time: z.string().optional().describe('任务提交时间'), + scheduled_time: z.string().optional().describe('任务调度时间'), + end_time: z.string().optional().describe('任务结束时间'), + orig_prompt: z.string().optional().describe('原始提示词'), + actual_prompt: z.string().optional().describe('实际使用的提示词'), + }).describe('输出结果'), + usage: z.object({ + video_count: z.number().describe('视频数量'), + video_duration: z.number().describe('视频时长'), + video_ratio: z.string().describe('视频分辨率'), + }).nullable().optional().describe('使用量统计'), +}) + +export class DashscopeCallbackDto extends createZodDto(dashscopeCallbackSchema) {} + +// Dashscope任务查询DTO +const dashscopeTaskQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + taskId: z.string().min(1).describe('任务ID'), +}) + +export class DashscopeTaskQueryDto extends createZodDto(dashscopeTaskQuerySchema) {} + +// Sora2视频生成请求 +const sora2GenerationRequestSchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + model: z.string().describe('模型'), + images: z.string().array().optional(), + orientation: z.enum(VideoOrientation), + prompt: z.string(), + size: z.enum(VideoSize), + duration: z.union([z.literal(10), z.literal(15)]), +}) + +export class Sora2GenerationRequestDto extends createZodDto(sora2GenerationRequestSchema) {} + +// Sora2任务查询DTO +const sora2TaskQuerySchema = z.object({ + userId: z.string(), + userType: z.enum(UserType), + taskId: z.string().min(1).describe('任务ID'), +}) + +export class Sora2TaskQueryDto extends createZodDto(sora2TaskQuerySchema) {} + +// 视频生成模型查询DTO +const videoGenerationModelsQuerySchema = z.object({ + userId: z.string().optional().describe('用户ID'), + userType: z.enum(UserType).optional().describe('用户类型'), +}) + +export class VideoGenerationModelsQueryDto extends createZodDto(videoGenerationModelsQuerySchema) {} + +// Sora2回调接口DTO(与查询API返回格式一致) +const sora2CallbackSchema = z.object({ + id: z.string(), + status: z.enum(Sora2TaskStatus), + video_url: z.string().optional(), + thumbnail_url: z.string().optional(), + status_update_time: z.number(), + finish_reason: z.string().optional(), +}) + +export class Sora2CallbackDto extends createZodDto(sora2CallbackSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.module.ts new file mode 100644 index 000000000..28e559a10 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common' +import { config } from '../../../config' +import { DashscopeModule } from '../../libs/dashscope' +import { KlingModule } from '../../libs/kling' +import { Sora2Module } from '../../libs/sora2' +import { VolcengineModule } from '../../libs/volcengine' +import { ModelsConfigModule } from '../models-config' +import { VideoService } from './video.service' + +@Module({ + imports: [ + KlingModule.forRoot(config.ai.kling), + VolcengineModule.forRoot(config.ai.volcengine), + DashscopeModule.forRoot(config.ai.dashscope), + Sora2Module.forRoot(config.ai.sora2), + ModelsConfigModule, + ], + controllers: [], + providers: [VideoService], + exports: [VideoService], +}) +export class VideoModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.service.ts new file mode 100644 index 000000000..f44ab8d31 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.service.ts @@ -0,0 +1,1249 @@ +import path from 'node:path' +import { BadRequestException, Injectable, Logger } from '@nestjs/common' +import { S3Service } from '@yikart/aws-s3' +import { AppException, ResponseCode, UserType } from '@yikart/common' +import { AiLog, AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' +import dayjs from 'dayjs' +import _ from 'lodash' +import { config } from '../../../config' +import { PointsService } from '../../../user/points.service' +import { UserService } from '../../../user/user.service' +import { DashscopeAction, KlingAction, TaskStatus } from '../../common/enums' +import { DashscopeService, TaskStatus as DashscopeTaskStatus, GetVideoTaskResponse } from '../../libs/dashscope' +import { + Image2VideoCreateTaskResponseData, + KlingService, + TaskStatus as KlingTaskStatus, + Mode, + MultiImage2VideoCreateTaskResponseData, + Text2VideoCreateTaskResponseData, + Text2VideoGetTaskResponseData, +} from '../../libs/kling' +import { + GetVideoGenerationTaskResponse as Sora2GetVideoGenerationTaskResponse, + Sora2Service, + TaskStatus as Sora2TaskStatus, + VideoOrientation, + VideoSize, +} from '../../libs/sora2' +import { + Content, + ContentType, + CreateVideoGenerationTaskResponse, + GetVideoGenerationTaskResponse, + ImageRole, + parseModelTextCommand, + serializeModelTextCommand, + VolcengineService, + TaskStatus as VolcTaskStatus, +} from '../../libs/volcengine' +import { ModelsConfigService } from '../models-config' +import { + DashscopeCallbackDto, + DashscopeImage2VideoRequestDto, + DashscopeKeyFrame2VideoRequestDto, + DashscopeText2VideoRequestDto, + KlingCallbackDto, + KlingImage2VideoRequestDto, + KlingMultiImage2VideoRequestDto, + KlingText2VideoRequestDto, + Sora2CallbackDto, + Sora2GenerationRequestDto, + UserListVideoTasksQueryDto, + UserVideoGenerationRequestDto, + UserVideoTaskQueryDto, + VideoGenerationModelsQueryDto, + VolcengineCallbackDto, + VolcengineGenerationRequestDto, +} from './video.dto' + +@Injectable() +export class VideoService { + private readonly logger = new Logger(VideoService.name) + constructor( + private readonly userService: UserService, + private readonly dashscopeService: DashscopeService, + private readonly klingService: KlingService, + private readonly volcengineService: VolcengineService, + private readonly sora2Service: Sora2Service, + private readonly aiLogRepo: AiLogRepository, + private readonly s3Service: S3Service, + private readonly modelsConfigService: ModelsConfigService, + private readonly pointsService: PointsService, + ) { } + + async calculateVideoGenerationPrice(params: { + model: string + userId?: string + userType?: UserType + resolution?: string + aspectRatio?: string + mode?: string + duration?: number + }): Promise { + const { model, userId, userType } = params + + // 查找对应的模型配置 + const modelConfig = (await this.getVideoGenerationModelParams({ userId, userType })).find(m => m.name === model) + if (!modelConfig) { + throw new AppException(ResponseCode.InvalidModel) + } + + const { resolution, aspectRatio, mode, duration } = { + ...modelConfig.defaults, + ...params, + } + + const pricingConfig = modelConfig.pricing.find((pricing) => { + const resolutionMatch = !pricing.resolution || !resolution || pricing.resolution === resolution + const aspectRatioMatch = !pricing.aspectRatio || !aspectRatio || pricing.aspectRatio === aspectRatio + const modeMatch = !pricing.mode || !mode || pricing.mode === mode + const durationMatch = !pricing.duration || !duration || pricing.duration === duration + + return resolutionMatch && aspectRatioMatch && modeMatch && durationMatch + }) + + if (!pricingConfig) { + throw new AppException(ResponseCode.InvalidModel) + } + + return pricingConfig.price + } + + /** + * 用户视频生成(通用接口) + */ + async userVideoGeneration(request: UserVideoGenerationRequestDto) { + const { model } = request + + // 查找模型配置以确定channel + const modelConfig = this.modelsConfigService.config.video.generation.find(m => m.name === model) + if (!modelConfig) { + throw new AppException(ResponseCode.InvalidModel) + } + + const channel = modelConfig.channel + + // 创建标准响应的辅助函数 + const createTaskResponse = (taskId: string) => ({ + task_id: taskId, + status: TaskStatus.Submitted, + message: '', + }) + + switch (channel) { + case AiLogChannel.Kling: + return this.handleKlingGeneration(request, createTaskResponse) + case AiLogChannel.Volcengine: + return this.handleVolcengineGeneration(request, createTaskResponse) + case AiLogChannel.Dashscope: + return this.handleDashscopeGeneration(request, createTaskResponse) + case AiLogChannel.Sora2: + return this.handleSora2Genration(request, createTaskResponse) + default: + throw new AppException(ResponseCode.InvalidModel) + } + } + + /** + * 处理Kling渠道的视频生成 + */ + private async handleKlingGeneration( + request: UserVideoGenerationRequestDto, + createTaskResponse: (taskId: string) => T, + ) { + const { userId, userType, model, prompt, mode, duration, image, image_tail } = request + if (Array.isArray(image)) { + throw new BadRequestException() + } + const klingMode = mode === 'std' ? Mode.Std : mode === 'pro' ? Mode.Pro : undefined + const klingDuration = duration ? duration.toString() as '5' | '10' : undefined + + if (image) { + const klingRequest: KlingImage2VideoRequestDto = { + userId, + userType, + model_name: model, + image, + image_tail, + prompt, + mode: klingMode, + duration: klingDuration, + } + const result = await this.klingImage2Video(klingRequest) + return createTaskResponse(result.task_id) + } + else { + const klingRequest: KlingText2VideoRequestDto = { + userId, + userType, + model_name: model, + prompt, + mode: klingMode, + duration: klingDuration, + } + const result = await this.klingText2Video(klingRequest) + return createTaskResponse(result.task_id) + } + } + + /** + * 处理Volcengine渠道的视频生成 + */ + private async handleVolcengineGeneration( + request: UserVideoGenerationRequestDto, + createTaskResponse: (taskId: string) => T, + ) { + const { userId, userType, model, prompt, duration, size, image, image_tail } = request + + if (Array.isArray(image)) { + throw new BadRequestException() + } + + const textCommand = parseModelTextCommand(prompt) + const content: Content[] = [] + + // 添加图片内容 + if (image) { + content.push({ + type: ContentType.ImageUrl, + image_url: { url: image }, + role: ImageRole.FirstFrame, + }) + } + + if (image_tail) { + content.push({ + type: ContentType.ImageUrl, + image_url: { url: image_tail }, + role: ImageRole.LastFrame, + }) + } + + // 添加文本内容 + content.push({ + type: ContentType.Text, + text: `${textCommand.prompt} ${serializeModelTextCommand({ + ...textCommand.params, + duration, + resolution: size, + })}`, + }) + + const volcengineRequest: VolcengineGenerationRequestDto = { + userId, + userType, + model, + content, + } + const result = await this.volcengineCreate(volcengineRequest) + return createTaskResponse(result.id) + } + + /** + * 处理Dashscope渠道的视频生成 + */ + private async handleDashscopeGeneration( + request: UserVideoGenerationRequestDto, + createTaskResponse: (taskId: string) => T, + ) { + const { userId, userType, model, prompt, duration, size, image, image_tail } = request + + if (Array.isArray(image)) { + throw new BadRequestException() + } + + if (image && image_tail) { + const dashscopeRequest: DashscopeKeyFrame2VideoRequestDto = { + userId, + userType, + model, + input: { + first_frame_url: image, + last_frame_url: image_tail, + prompt, + }, + parameters: { + resolution: size, + duration, + }, + } + const result = await this.dashscopeKeyFrame2Video(dashscopeRequest) + return createTaskResponse(result.task_id) + } + else if (image && !image_tail) { + const dashscopeRequest: DashscopeImage2VideoRequestDto = { + userId, + userType, + model, + input: { + image_url: image, + prompt, + }, + parameters: { + resolution: size, + }, + } + const result = await this.dashscopeImage2Video(dashscopeRequest) + return createTaskResponse(result.task_id) + } + else { + const dashscopeRequest: DashscopeText2VideoRequestDto = { + userId, + userType, + model, + input: { + prompt, + }, + parameters: { + size, + duration, + }, + } + const result = await this.dashscopeText2Video(dashscopeRequest) + return createTaskResponse(result.task_id) + } + } + + /** + * + * 处理Sora2渠道的视频生成 + */ + private async handleSora2Genration( + request: UserVideoGenerationRequestDto, + createTaskResponse: (taskId: string) => T, + ) { + const { userId, userType, model, prompt, duration, size, image, metadata } = request + + const sora2Request: Sora2GenerationRequestDto = { + userId, + userType, + model, + prompt, + duration: duration as 10 | 15, + size: (size || VideoSize.Large) as VideoSize, + images: (Array.isArray(image) ? image : [image]) as (string[] | undefined), + orientation: (metadata?.['orientation'] || VideoOrientation.Landscape) as VideoOrientation, + } + const result = await this.sora2Create(sora2Request) + return createTaskResponse(result.id) + } + + async transformToCommonResponse(aiLog: AiLog) { + if (aiLog.status === AiLogStatus.Generating) { + return { + task_id: aiLog.id, + action: aiLog.action || '', + status: TaskStatus.InProgress, + fail_reason: '', + submit_time: Math.floor(aiLog.startedAt.getTime() / 1000), + start_time: Math.floor(aiLog.startedAt.getTime() / 1000), + finish_time: 0, + progress: '30%', + data: {}, + } + } + + if (!aiLog.response) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + + const result = { + task_id: aiLog.id, + action: aiLog.action || '', + status: aiLog.status === AiLogStatus.Success ? TaskStatus.Success : TaskStatus.Failure, + fail_reason: '', + submit_time: Math.floor(aiLog.startedAt.getTime() / 1000), + start_time: Math.floor(aiLog.startedAt.getTime() / 1000), + finish_time: Math.floor((aiLog.startedAt.getTime() + (aiLog.duration || 0)) / 1000), + progress: '100%', + data: {}, + } + + if (aiLog.channel === AiLogChannel.Kling) { + return Object.assign(result, await this.getKlingTaskResult(aiLog.response as unknown as Text2VideoGetTaskResponseData)) + } + else if (aiLog.channel === AiLogChannel.Volcengine) { + return Object.assign(result, await this.getVolcengineTaskResult(aiLog.response as unknown as GetVideoGenerationTaskResponse)) + } + else if (aiLog.channel === AiLogChannel.Dashscope) { + return Object.assign(result, await this.getDashscopeTaskResult(aiLog.response as unknown as GetVideoTaskResponse)) + } + else if (aiLog.channel === AiLogChannel.Sora2) { + return Object.assign(result, await this.getSora2TaskResult(aiLog.response as unknown as Sora2GetVideoGenerationTaskResponse)) + } + else { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + } + + /** + * 查询视频任务状态 + */ + async getVideoTaskStatus(request: UserVideoTaskQueryDto) { + const { taskId } = request + + const aiLog = await this.aiLogRepo.getById(taskId) + + if (aiLog == null || aiLog.type !== AiLogType.Video) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + return this.transformToCommonResponse(aiLog) + } + + async listVideoTasks(request: UserListVideoTasksQueryDto) { + const [aiLogs, count] = await this.aiLogRepo.listWithPagination({ + ...request, + type: AiLogType.Video, + }) + + return [await Promise.all(aiLogs.map(log => this.transformToCommonResponse(log))), count] as const + } + + /** + * 获取视频生成模型参数 + */ + async getVideoGenerationModelParams(data: VideoGenerationModelsQueryDto) { + if (data.userType === UserType.User && data.userId) { + try { + const user = await this.userService.getUserInfoById(data.userId) + if (user && user.vipInfo && dayjs(user.vipInfo.expireTime).isAfter(dayjs())) { + const models = _.cloneDeep(this.modelsConfigService.config.video.generation) + const targetModel = models.find((model: { name: string }) => model.name === 'sora-2') + if (targetModel) { + targetModel.pricing.forEach((price) => { + price.price = 0 + }) + } + return models + } + } + catch (error) { + this.logger.warn({ error }) + } + } + + return this.modelsConfigService.config.video.generation + } + + /** + * Kling文生视频 + */ + /** + * Dashscope文生视频 + */ + async dashscopeText2Video(request: DashscopeText2VideoRequestDto) { + const { userId, userType, model, parameters, ...restParams } = request + const pricing = await this.calculateVideoGenerationPrice({ + model, + duration: parameters?.duration, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model, + }) + } + + const startedAt = new Date() + const result = await this.dashscopeService.createTextToVideoTask({ model, parameters, ...restParams }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.output.task_id, + model, + channel: AiLogChannel.Dashscope, + action: DashscopeAction.Text2Video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { model, parameters, ...restParams }, + status: AiLogStatus.Generating, + }) + + return { + ...result.output, + task_id: aiLog.id, + } + } + + async klingText2Video(request: KlingText2VideoRequestDto) { + const { userId, userType, model_name, duration, mode, ...params } = request + const pricing = await this.calculateVideoGenerationPrice({ + model: model_name, + mode, + duration: duration ? Number(duration) : undefined, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model_name, + }) + } + + const startedAt = new Date() + const result = await this.klingService.createText2VideoTask({ + ...params, + model_name, + mode, + duration, + callback_url: config.ai.kling.callbackUrl, + }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.data.task_id, + model: model_name, + channel: AiLogChannel.Kling, + action: KlingAction.Text2Video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { ...params, mode, duration, model_name }, + status: AiLogStatus.Generating, + }) + + return { + ...result.data, + task_id: aiLog.id, + } as Text2VideoCreateTaskResponseData + } + + /** + * Kling回调处理 + */ + async klingCallback(callbackData: KlingCallbackDto) { + const { task_id, task_status, task_status_msg, task_result, updated_at } = callbackData + + const aiLog = await this.aiLogRepo.getByTaskId(task_id) + if (!aiLog || aiLog.channel !== AiLogChannel.Kling) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + + if (task_status !== KlingTaskStatus.Succeed && task_status !== KlingTaskStatus.Failed) { + return + } + + let status: AiLogStatus + switch (task_status) { + case KlingTaskStatus.Succeed: + status = AiLogStatus.Success + break + case KlingTaskStatus.Failed: + status = AiLogStatus.Failed + break + default: + status = AiLogStatus.Generating + break + } + + const duration = updated_at - aiLog.startedAt.getTime() + for (const video of task_result?.videos || []) { + const filename = `${aiLog.id}-${video.id}.mp4` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(video.url, fullPath) + video.url = result.path + } + for (const image of task_result?.images || []) { + const filename = `${aiLog.id}-${image.index}.png` + const fullPath = path.join(`ai/image/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(image.url, fullPath) + image.url = result.path + } + + await this.aiLogRepo.updateById(aiLog.id, { + status, + response: callbackData, + duration, + errorMessage: task_status === 'failed' ? task_status_msg : undefined, + }) + + if (status === AiLogStatus.Failed && aiLog.userType === UserType.User) { + await this.pointsService.addPoints({ + userId: aiLog.userId, + amount: aiLog.points, + type: 'ai_service', + description: aiLog.model, + }) + } + } + + /** + * 查询Kling任务状态 + */ + async getKlingTaskResult(data: Text2VideoGetTaskResponseData) { + const status = { + [KlingTaskStatus.Succeed]: TaskStatus.Success, + [KlingTaskStatus.Submitted]: TaskStatus.Submitted, + [KlingTaskStatus.Processing]: TaskStatus.InProgress, + [KlingTaskStatus.Failed]: TaskStatus.Failure, + }[data.task_status] + + return { + status, + fail_reason: data.task_result.videos[0].url || data.task_status_msg || '', + data: data.task_result || {}, + } + } + + async getKlingTask(userId: string, userType: UserType, logId: string) { + const aiLog = await this.aiLogRepo.getByIdAndUserId(logId, userId, userType) + + if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Kling) { + this.logger.debug({ + userId, + userType, + logId, + aiLog, + }, 'InvalidAiTaskId') + throw new AppException(ResponseCode.InvalidAiTaskId) + } + if (aiLog.status === AiLogStatus.Generating) { + let result: KlingCallbackDto + switch (aiLog.action) { + case KlingAction.Image2video: + result = (await this.klingService.getImage2VideoTask(aiLog.taskId)).data + break + case KlingAction.MultiImage2video: + result = (await this.klingService.getMultiImage2VideoTask(aiLog.taskId)).data + break + case KlingAction.MultiElements: + result = (await this.klingService.getMultiElementsTask(aiLog.taskId)).data + break + case KlingAction.VideoExtend: + result = (await this.klingService.getVideoExtendTask(aiLog.taskId)).data + break + case KlingAction.LipSync: + result = (await this.klingService.getLipSyncTask(aiLog.taskId)).data + break + case KlingAction.Effects: + result = (await this.klingService.getVideoEffectsTask(aiLog.taskId)).data + break + case KlingAction.Text2Video: + default: + result = (await this.klingService.getText2VideoTask(aiLog.taskId)).data + } + + if (result.task_status === KlingTaskStatus.Succeed || result.task_status === KlingTaskStatus.Failed) { + await this.klingCallback(result) + } + return result + } + return aiLog.response as unknown as KlingCallbackDto + } + + /** + * Volcengine回调处理 + */ + async volcengineCallback(callbackData: VolcengineCallbackDto) { + const { id, status, updated_at, content } = callbackData + + const aiLog = await this.aiLogRepo.getByTaskId(id) + if (!aiLog || aiLog.channel !== AiLogChannel.Volcengine) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + + if (status !== VolcTaskStatus.Succeeded && status !== VolcTaskStatus.Failed) { + return + } + + let aiLogStatus: AiLogStatus + switch (status) { + case VolcTaskStatus.Succeeded: + aiLogStatus = AiLogStatus.Success + break + case VolcTaskStatus.Failed: + aiLogStatus = AiLogStatus.Failed + break + default: + aiLogStatus = AiLogStatus.Generating + break + } + + if (content) { + if (content.last_frame_url) { + const filename = `${aiLog.id}-last_frame_url.png` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(content.last_frame_url, fullPath) + content.last_frame_url = result.path + } + + const filename = `${aiLog.id}.mp4` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(content.video_url, fullPath) + content.video_url = result.path + } + + const duration = (updated_at * 1000) - aiLog.startedAt.getTime() + + await this.aiLogRepo.updateById(aiLog.id, { + status: aiLogStatus, + response: callbackData, + duration, + errorMessage: status === 'failed' ? callbackData.error?.message : undefined, + }) + + if (aiLogStatus === AiLogStatus.Failed && aiLog.userType === UserType.User) { + await this.pointsService.addPoints({ + userId: aiLog.userId, + amount: aiLog.points, + type: 'ai_service', + description: aiLog.model, + }) + } + } + + /** + * Volcengine视频生成 + */ + async volcengineCreate(request: VolcengineGenerationRequestDto) { + const { userId, userType, model, content, ...params } = request + + const prompt = content.find(c => c.type === ContentType.Text)?.text + + if (!prompt) { + throw new BadRequestException('prompt is required') + } + + const { params: modelParams } = parseModelTextCommand(prompt) + + const pricing = await this.calculateVideoGenerationPrice({ + aspectRatio: modelParams.ratio, + resolution: modelParams.resolution, + duration: modelParams.duration, + model, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model, + }) + } + + const startedAt = new Date() + const result = await this.volcengineService.createVideoGenerationTask({ + ...params, + model, + content, + callback_url: config.ai.volcengine.callbackUrl, + }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.id, + model, + channel: AiLogChannel.Volcengine, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { + ...params, + model, + content, + }, + status: AiLogStatus.Generating, + }) + + return { + ...result, + id: aiLog.id, + } as CreateVideoGenerationTaskResponse + } + + /** + * 查询Volcengine任务状态 + */ + async getVolcengineTaskResult(result: GetVideoGenerationTaskResponse) { + const status = { + [VolcTaskStatus.Succeeded]: TaskStatus.Success, + [VolcTaskStatus.Queued]: TaskStatus.Submitted, + [VolcTaskStatus.Running]: TaskStatus.InProgress, + [VolcTaskStatus.Failed]: TaskStatus.Failure, + [VolcTaskStatus.Cancelled]: TaskStatus.Failure, + }[result.status] + + return { + status, + fail_reason: result.content?.video_url || result.error?.message || '', + data: result.content || {}, + } + } + + async getVolcengineTask(userId: string, userType: UserType, taskId: string) { + const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType) + + if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Volcengine) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + if (aiLog.status === AiLogStatus.Generating) { + const result = await this.volcengineService.getVideoGenerationTask(aiLog.taskId) + if (result.status === VolcTaskStatus.Succeeded || result.status === VolcTaskStatus.Failed) { + await this.volcengineCallback(result) + } + return result + } + return aiLog.response as unknown as GetVideoGenerationTaskResponse + } + + /** + * Kling图生视频 + */ + async klingImage2Video(request: KlingImage2VideoRequestDto) { + const { userId, userType, model_name, duration, mode, ...params } = request + const pricing = await this.calculateVideoGenerationPrice({ + model: model_name, + mode, + duration: duration ? Number(duration) : undefined, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model_name, + }) + } + + const startedAt = new Date() + const result = await this.klingService.createImage2VideoTask({ + ...params, + model_name, + mode, + duration, + callback_url: config.ai.kling.callbackUrl, + }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.data.task_id, + model: model_name, + channel: AiLogChannel.Kling, + action: KlingAction.Image2video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { ...params, mode, duration, model_name }, + status: AiLogStatus.Generating, + }) + + return { + ...result.data, + task_id: aiLog.id, + } as Image2VideoCreateTaskResponseData + } + + /** + * Kling多图生视频 + */ + async klingMultiImage2Video(request: KlingMultiImage2VideoRequestDto) { + const { userId, userType, model_name, duration, mode, ...params } = request + const pricing = await this.calculateVideoGenerationPrice({ + model: model_name, + mode, + duration: duration ? Number(duration) : undefined, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model_name, + }) + } + + const startedAt = new Date() + const result = await this.klingService.createMultiImage2VideoTask({ + ...params, + model_name, + mode, + duration, + callback_url: config.ai.kling.callbackUrl, + }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.data.task_id, + model: model_name, + channel: AiLogChannel.Kling, + action: KlingAction.MultiImage2video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { ...params, mode, duration, model_name }, + status: AiLogStatus.Generating, + }) + + return { + ...result.data, + task_id: aiLog.id, + } as MultiImage2VideoCreateTaskResponseData + } + + /** + * Dashscope图生视频 + */ + async dashscopeImage2Video(request: DashscopeImage2VideoRequestDto) { + const { userId, userType, model, parameters, ...restParams } = request + const pricing = await this.calculateVideoGenerationPrice({ + model, + resolution: parameters?.resolution, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model, + }) + } + + const startedAt = new Date() + const result = await this.dashscopeService.createImageToVideoTask({ model, parameters, ...restParams }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.output.task_id, + model, + channel: AiLogChannel.Dashscope, + action: DashscopeAction.Image2Video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { model, parameters, ...restParams }, + status: AiLogStatus.Generating, + }) + + return { + ...result.output, + task_id: aiLog.id, + } + } + + /** + * Dashscope回调处理 + */ + async dashscopeCallback(callbackData: DashscopeCallbackDto) { + const { output } = callbackData + const { task_id, task_status, video_url, end_time } = output + + const aiLog = await this.aiLogRepo.getByTaskId(task_id) + if (!aiLog || aiLog.channel !== AiLogChannel.Dashscope) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + + if (task_status !== DashscopeTaskStatus.Succeeded && task_status !== DashscopeTaskStatus.Failed) { + return + } + + let status: AiLogStatus + switch (task_status) { + case DashscopeTaskStatus.Succeeded: + status = AiLogStatus.Success + break + case DashscopeTaskStatus.Failed: + status = AiLogStatus.Failed + break + default: + status = AiLogStatus.Generating + break + } + + const duration = end_time ? new Date(end_time).getTime() - aiLog.startedAt.getTime() : undefined + + // 如果任务成功且有视频URL,保存到S3 + if (status === AiLogStatus.Success && video_url) { + const filename = `${aiLog.id}.mp4` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(video_url, fullPath) + callbackData.output.video_url = result.path + } + + await this.aiLogRepo.updateById(aiLog.id, { + status, + response: callbackData, + duration, + errorMessage: status === AiLogStatus.Failed ? callbackData.message : undefined, + }) + + if (status === AiLogStatus.Failed && aiLog.userType === UserType.User) { + await this.pointsService.addPoints({ + userId: aiLog.userId, + amount: aiLog.points, + type: 'ai_service', + description: aiLog.model, + }) + } + } + + /** + * Dashscope任务查询 + */ + async getDashscopeTask(userId: string, userType: UserType, taskId: string) { + const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType) + + if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Dashscope) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + if (aiLog.status === AiLogStatus.Generating) { + const result = await this.dashscopeService.getVideoTask(aiLog.taskId) + if (result.output.task_status === DashscopeTaskStatus.Succeeded || result.output.task_status === DashscopeTaskStatus.Failed) { + await this.dashscopeCallback(result) + } + return result + } + return aiLog.response as unknown as GetVideoTaskResponse + } + + async getDashscopeTaskResult(result: GetVideoTaskResponse) { + const { output } = result + const { task_status } = output + + let status: TaskStatus + switch (task_status) { + case DashscopeTaskStatus.Succeeded: + status = TaskStatus.Success + break + case DashscopeTaskStatus.Failed: + status = TaskStatus.Failure + break + default: + status = TaskStatus.InProgress + break + } + + return { + fail_reason: status === TaskStatus.Failure ? result.message : '', + data: output, + } + } + + /** + * Dashscope首尾帧生视频 + */ + async dashscopeKeyFrame2Video(request: DashscopeKeyFrame2VideoRequestDto) { + const { userId, userType, model, parameters, ...restParams } = request + const pricing = await this.calculateVideoGenerationPrice({ + model, + resolution: parameters?.resolution, + duration: parameters?.duration, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model, + }) + } + + const startedAt = new Date() + const result = await this.dashscopeService.createKeyFrameToVideoTask({ model, parameters, ...restParams }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.output.task_id, + model, + channel: AiLogChannel.Dashscope, + action: DashscopeAction.KeyFrame2Video, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { model, parameters, ...restParams }, + status: AiLogStatus.Generating, + }) + + return { + ...result.output, + task_id: aiLog.id, + } + } + + /** + * Sora2视频生成 + */ + async sora2Create(request: Sora2GenerationRequestDto) { + const { userId, userType, model, prompt, ...params } = request + + const pricing = await this.calculateVideoGenerationPrice({ + duration: params.duration, + resolution: params.size, + model, + }) + + if (userType === UserType.User) { + const balance = await this.pointsService.getBalance(userId) + if (balance < pricing) { + throw new AppException(ResponseCode.UserPointsInsufficient) + } + + await this.pointsService.deductPoints({ + userId, + amount: pricing, + type: 'ai_service', + description: model, + }) + } + + const startedAt = new Date() + const result = await this.sora2Service.createVideoGenerationTask({ + ...params, + prompt, + model, + }) + + const aiLog = await this.aiLogRepo.create({ + userId, + userType, + taskId: result.id, + model, + channel: AiLogChannel.Sora2, + startedAt, + type: AiLogType.Video, + points: pricing, + request: { + ...params, + model, + prompt, + }, + status: AiLogStatus.Generating, + }) + + return { + ...result, + id: aiLog.id, + } as CreateVideoGenerationTaskResponse + } + + /** + * 查询Sora2任务状态 + */ + async getSora2TaskResult(result: Sora2GetVideoGenerationTaskResponse) { + return { + fail_reason: result?.video_url || result.finish_reason || '', + data: result || {}, + } + } + + async getSora2Task(userId: string, userType: UserType, taskId: string) { + const aiLog = await this.aiLogRepo.getByIdAndUserId(taskId, userId, userType) + + if (aiLog == null || !aiLog.taskId || aiLog.type !== AiLogType.Video || aiLog.channel !== AiLogChannel.Sora2) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + if (aiLog.status === AiLogStatus.Generating) { + const result = await this.sora2Service.getVideoGenerationTask(aiLog.taskId) + if (result.status === Sora2TaskStatus.Completed || result.status === Sora2TaskStatus.Failed) { + await this.sora2Callback(result) + } + return result + } + return aiLog.response as unknown as GetVideoGenerationTaskResponse + } + + async sora2Callback(data: Sora2CallbackDto) { + const { id, status, status_update_time } = data + + const aiLog = await this.aiLogRepo.getByTaskId(id) + if (!aiLog || aiLog.channel !== AiLogChannel.Sora2) { + throw new AppException(ResponseCode.InvalidAiTaskId) + } + + if (status !== Sora2TaskStatus.Completed && status !== Sora2TaskStatus.Failed) { + return + } + + let aiLogStatus: AiLogStatus + switch (status) { + case Sora2TaskStatus.Completed: + aiLogStatus = AiLogStatus.Success + break + case Sora2TaskStatus.Failed: + aiLogStatus = AiLogStatus.Failed + break + default: + aiLogStatus = AiLogStatus.Generating + break + } + + if (data.video_url) { + const filename = `${aiLog.id}.mp4` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(data.video_url, fullPath) + data.video_url = result.path + } + + if (data.thumbnail_url) { + const filename = `${aiLog.id}-thumbnail.webp` + const fullPath = path.join(`ai/video/${aiLog.model}`, aiLog.userId, filename) + const result = await this.s3Service.putObjectFromUrl(data.thumbnail_url, fullPath) + data.thumbnail_url = result.path + } + + const duration = (status_update_time) - aiLog.startedAt.getTime() + + await this.aiLogRepo.updateById(aiLog.id, { + status: aiLogStatus, + response: data, + duration, + errorMessage: status === Sora2TaskStatus.Failed ? data.finish_reason : undefined, + }) + + if (aiLogStatus === AiLogStatus.Failed && aiLog.userType === UserType.User) { + await this.pointsService.addPoints({ + userId: aiLog.userId, + amount: aiLog.points, + type: 'ai_service', + description: aiLog.model, + }) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.vo.ts new file mode 100644 index 000000000..de95f84f3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/core/video/video.vo.ts @@ -0,0 +1,177 @@ +import { createPaginationVo, createZodDto } from '@yikart/common' +import { AiLogChannel } from '@yikart/mongodb' +import { z } from 'zod' +import { + TaskStatus as DashscopeTaskStatus, +} from '../../libs/dashscope' +import { TaskStatus as Sora2TaskStatus } from '../../libs/sora2' + +// Kling视频生成响应 +const klingVideoGenerationResponseSchema = z.object({ + task_id: z.string(), + task_status: z.string().optional(), +}) + +// Volcengine视频生成响应 +const volcengineVideoGenerationResponseSchema = z.object({ + id: z.string(), +}) + +// 通用视频生成响应 +const videoGenerationResponseSchema = z.object({ + task_id: z.string(), + status: z.string(), +}) + +export class KlingVideoGenerationResponseVo extends createZodDto(klingVideoGenerationResponseSchema) {} +export class VolcengineVideoGenerationResponseVo extends createZodDto(volcengineVideoGenerationResponseSchema) {} +export class VideoGenerationResponseVo extends createZodDto(videoGenerationResponseSchema) {} +// Kling 任务状态响应 VO +const klingTaskStatusResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.string().describe('任务状态'), + task_status_msg: z.string().describe('任务状态信息'), + task_info: z.object({ + parent_video: z.object({ + id: z.string(), + url: z.string(), + duration: z.string(), + }).optional(), + external_task_id: z.string().optional(), + }).optional().describe('任务信息'), + task_result: z.object({ + images: z.array(z.object({ + index: z.number(), + url: z.string(), + })).optional(), + videos: z.array(z.object({ + id: z.string(), + url: z.string(), + duration: z.string(), + })).optional(), + }).optional().describe('任务结果'), + created_at: z.number().describe('创建时间'), + updated_at: z.number().describe('更新时间'), +}) + +export class KlingTaskStatusResponseVo extends createZodDto(klingTaskStatusResponseSchema) {} + +// Volcengine 任务状态响应 VO +const volcengineTaskStatusResponseSchema = z.object({ + id: z.string().describe('任务ID'), + model: z.string().describe('模型名称'), + status: z.string().describe('任务状态'), + error: z.object({ + message: z.string(), + code: z.string(), + }).nullable().describe('错误信息'), + created_at: z.number().describe('创建时间'), + updated_at: z.number().describe('更新时间'), + content: z.object({ + video_url: z.string().optional(), + last_frame_url: z.string().optional(), + }).optional().describe('视频内容'), + seed: z.number().optional().describe('种子值'), + resolution: z.string().optional().describe('分辨率'), + ratio: z.string().optional().describe('宽高比'), + duration: z.number().optional().describe('时长'), + framespersecond: z.number().optional().describe('帧率'), + usage: z.object({ + completion_tokens: z.number().optional(), + total_tokens: z.number().optional(), + }).optional().describe('使用量统计'), +}) + +export class VolcengineTaskStatusResponseVo extends createZodDto(volcengineTaskStatusResponseSchema) {} + +// 通用视频任务状态响应 +const videoTaskStatusResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + action: z.string().describe('任务动作'), + status: z.string().describe('任务状态'), + fail_reason: z.string().optional().describe('失败原因或视频URL'), + submit_time: z.number().describe('提交时间'), + start_time: z.number().describe('开始时间'), + finish_time: z.number().describe('完成时间'), + progress: z.string().describe('任务进度'), + data: z.any(), +}) + +export class VideoTaskStatusResponseVo extends createZodDto(videoTaskStatusResponseSchema) {} + +export class ListVideoTasksResponseVo extends createPaginationVo(videoTaskStatusResponseSchema) {} + +// 视频生成模型参数 VO +const videoGenerationModelSchema = z.object({ + name: z.string().describe('模型名称'), + description: z.string().describe('模型描述'), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + channel: z.enum(AiLogChannel), + modes: z.array(z.enum(['text2video', 'image2video', 'flf2video', 'lf2video', 'multi-image2video'])), + resolutions: z.array(z.string()).describe('支持的尺寸'), + durations: z.array(z.number()).describe('支持的时长'), + supportedParameters: z.array(z.string()).describe('支持的参数'), + defaults: z.object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + }).optional(), + pricing: z.object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + price: z.number(), + discount: z.string().optional(), + originPrice: z.number().optional(), + }).array(), +}) + +export class VideoGenerationModelParamsVo extends createZodDto(videoGenerationModelSchema) {} + +// Dashscope 视频生成响应 VO +const dashscopeVideoGenerationResponseSchema = z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.enum(DashscopeTaskStatus).optional().describe('任务状态'), +}) + +export class DashscopeVideoGenerationResponseVo extends createZodDto(dashscopeVideoGenerationResponseSchema) {} + +// Dashscope 任务状态响应 VO +const dashscopeTaskStatusResponseSchema = z.object({ + status_code: z.number().describe('HTTP状态码'), + request_id: z.string().describe('请求ID'), + code: z.string().nullable().describe('错误码'), + message: z.string().describe('错误消息'), + output: z.object({ + task_id: z.string().describe('任务ID'), + task_status: z.enum(DashscopeTaskStatus).describe('任务状态'), + video_url: z.string().optional().describe('视频URL'), + submit_time: z.string().optional().describe('任务提交时间'), + scheduled_time: z.string().optional().describe('任务调度时间'), + end_time: z.string().optional().describe('任务结束时间'), + orig_prompt: z.string().optional().describe('原始提示词'), + actual_prompt: z.string().optional().describe('实际使用的提示词'), + }).describe('输出结果'), + usage: z.object({ + video_count: z.number().describe('视频数量'), + video_duration: z.number().describe('视频时长'), + video_ratio: z.string().describe('视频分辨率'), + }).nullable().optional().describe('使用量统计'), +}) + +export class DashscopeTaskStatusResponseVo extends createZodDto(dashscopeTaskStatusResponseSchema) {} + +const sora2TaskStatusResponseSchema = z.object({ + id: z.string(), + status: z.enum(Sora2TaskStatus), + video_url: z.string(), + status_update_time: z.number(), + finish_reason: z.string(), +}) + +export class Sora2TaskStatusResponseVo extends createZodDto(sora2TaskStatusResponseSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-chat.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-chat.dto.ts new file mode 100644 index 000000000..9f9a2cf4a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-chat.dto.ts @@ -0,0 +1,46 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const messageContentTextSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}) + +export const messageContentImageUrlSchema = z.object({ + type: z.literal('image_url'), + image_url: z.object({ + url: z.url(), + detail: z.enum(['auto', 'low', 'high']).optional(), + }), +}) +const complexObjectSchema = z.record(z.string(), z.any()).and(z.object({ + type: z.string().optional(), +})) + +const genericObjectSchema = z.record(z.string(), z.any()).and(z.object({ + type: z.undefined(), +})) + +export const messageContentComplexSchema = z.union([ + messageContentTextSchema, + messageContentImageUrlSchema, + complexObjectSchema, + genericObjectSchema, +]) + +const chatMessageSchema = z.object({ + role: z.string().describe('消息角色'), + content: z.union([z.string(), z.array(messageContentComplexSchema)]).describe('消息内容'), +}) + +const chatCompletionDtoSchema = z.object({ + messages: z.array(chatMessageSchema).min(1).describe('消息列表'), + model: z.string().describe('模型'), + temperature: z.number().min(0).max(2).optional().describe('温度参数'), + maxTokens: z.number().int().min(1).optional().describe('最大输出token数'), + maxCompletionTokens: z.number().optional(), + modalities: z.enum(['text', 'audio', 'image', 'video']).array().optional(), + topP: z.number().optional(), +}) + +export class ChatCompletionDto extends createZodDto(chatCompletionDtoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-image.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-image.dto.ts new file mode 100644 index 000000000..a984e9567 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-image.dto.ts @@ -0,0 +1,96 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { FireflycardTempTypes } from '../libs/fireflycard' + +// 图片生成请求 +const imageGenerationSchema = z.object({ + prompt: z.string().min(1).max(4000).describe('图片描述提示'), + model: z.string().describe('图片生成模型'), + n: z.number().int().min(1).max(10).optional().describe('生成图片数量'), + quality: z.string().optional().describe('图片质量'), + response_format: z.enum(['url', 'b64_json']).optional().describe('返回格式'), + size: z.string().optional().describe('图片尺寸'), + style: z.string().optional().describe('图片风格'), + user: z.string().optional().describe('用户标识符'), +}) + +export class ImageGenerationDto extends createZodDto(imageGenerationSchema) {} + +// 图片编辑请求 +const imageEditSchema = z.object({ + model: z.string().describe('图片编辑模型'), + image: z.string().or(z.string().array()).describe('原始图片'), + prompt: z.string().min(1).max(4000).describe('编辑描述'), + mask: z.string().optional().describe('遮罩图片'), + n: z.int().min(1).max(1).optional().describe('生成图片数量'), + size: z.string().optional().describe('图片尺寸'), + response_format: z.enum(['url', 'b64_json']).optional().describe('返回格式'), + user: z.string().optional().describe('用户标识符'), +}) + +export class ImageEditDto extends createZodDto(imageEditSchema) {} + +// MD2Card生成请求 +const md2CardSchema = z.object({ + markdown: z.string().describe('要转换的 Markdown 文本'), + theme: z.string().default('apple-notes').optional().describe('卡片主题样式 ID'), + themeMode: z.string().optional().describe('主题的模式 ID'), + width: z.int().min(100).max(2000).default(440).optional().describe('卡片宽度(像素)'), + height: z.int().min(100).max(3000).default(586).optional().describe('卡片高度(像素)'), + splitMode: z.string().default('noSplit').optional().describe('分割模式'), + mdxMode: z.boolean().default(false).optional().describe('是否启用 MDX 模式'), + overHiddenMode: z.boolean().default(false).optional().describe('是否启用溢出隐藏模式'), +}) + +export class Md2CardDto extends createZodDto(md2CardSchema) {} + +// Fireflycard模板类型枚举 +export const fireflycardTempSchema = z.enum(FireflycardTempTypes) + +// Fireflycard样式配置 +export const fireflycardStyleSchema = z.object({ + align: z.string().optional().describe('对齐方式'), + backgroundName: z.string().optional().describe('背景名称'), + backShadow: z.string().optional().describe('背景阴影'), + font: z.string().optional().describe('字体'), + width: z.number().optional().describe('宽度'), + ratio: z.string().optional().describe('比例'), + height: z.number().optional().describe('高度'), + fontScale: z.number().optional().describe('字体缩放'), + padding: z.string().optional().describe('内边距'), + borderRadius: z.string().optional().describe('边框圆角'), + color: z.string().optional().describe('颜色'), + opacity: z.number().optional().describe('透明度'), + blur: z.number().optional().describe('模糊度'), + backgroundAngle: z.string().optional().describe('背景角度'), + lineHeights: z.object({ + content: z.string().optional().describe('内容行高'), + }).optional().describe('行高设置'), + letterSpacings: z.object({ + content: z.string().optional().describe('内容字间距'), + }).optional().describe('字间距设置'), +}).optional() + +// Fireflycard开关配置 +export const fireflycardSwitchConfigSchema = z.object({ + showIcon: z.boolean().optional().describe('显示图标'), + showDate: z.boolean().optional().describe('显示日期'), + showTitle: z.boolean().optional().describe('显示标题'), + showContent: z.boolean().optional().describe('显示内容'), + showAuthor: z.boolean().optional().describe('显示作者'), + showTextCount: z.boolean().optional().describe('显示文字计数'), + showQRCode: z.boolean().optional().describe('显示二维码'), + showPageNum: z.boolean().optional().describe('显示页码'), + showWatermark: z.boolean().optional().describe('显示水印'), +}).optional() + +// Fireflycard生成请求 +const fireflyCardSchema = z.object({ + content: z.string().min(1).describe('卡片内容'), + temp: fireflycardTempSchema.default(FireflycardTempTypes.A).describe('模板类型'), + title: z.string().optional().describe('标题'), + style: fireflycardStyleSchema.describe('样式配置'), + switchConfig: fireflycardSwitchConfigSchema.describe('开关配置'), +}) + +export class FireflyCardDto extends createZodDto(fireflyCardSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-logs.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-logs.dto.ts new file mode 100644 index 000000000..ca1f3965c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-logs.dto.ts @@ -0,0 +1,16 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import { z } from 'zod' + +// 日志列表查询参数 +export const logListQuerySchema = z.object({ + ...PaginationDtoSchema.shape, +}) + +export class LogListQueryDto extends createZodDto(logListQuerySchema) {} + +// 日志详情查询请求 +const logDetailQuerySchema = z.object({ + id: z.string().min(1).describe('日志ID'), +}) + +export class LogDetailQueryDto extends createZodDto(logDetailQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-video.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-video.dto.ts new file mode 100644 index 000000000..7c3dbe63e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/ai-video.dto.ts @@ -0,0 +1,171 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import { z } from 'zod' +import { AspectRatio, Mode } from '../libs/kling' +import { ContentType, ImageRole } from '../libs/volcengine' +// 移除了不必要的类型导入,因为现在使用zod schema + +// 通用视频生成请求 +const videoGenerationRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + prompt: z.string().min(1).max(4000).describe('提示词'), + image: z.string().or(z.string().array()).optional().describe('图片URL或base64'), + image_tail: z.string().optional().describe('尾帧图片URL或base64'), + mode: z.string().optional().describe('生成模式'), + size: z.string().optional().describe('尺寸'), + duration: z.number().optional().describe('时长'), + metadata: z.record(z.string(), z.any()).optional().describe('其他参数'), +}) + +export class VideoGenerationRequestDto extends createZodDto(videoGenerationRequestSchema) {} + +// 通用视频任务状态查询 +const videoTaskQuerySchema = z.object({ + taskId: z.string().min(1).describe('任务ID'), +}) + +export class VideoTaskQueryDto extends createZodDto(videoTaskQuerySchema) {} + +// 通用视频任务状态查询 +const listUserVideoTasksQuerySchema = z.object({ + ...PaginationDtoSchema.shape, +}) + +export class UserListVideoTasksQueryDto extends createZodDto(listUserVideoTasksQuerySchema) {} + +// Kling文生视频请求 +const klingText2VideoRequestSchema = z.object({ + model_name: z.string().min(1).describe('模型名称'), + prompt: z.string().min(1).max(2500).describe('正向文本提示词'), + negative_prompt: z.string().max(2500).optional().describe('负向文本提示词'), + cfg_scale: z.number().min(0).max(1).optional().describe('生成视频的自由度'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), + external_task_id: z.string().optional().describe('自定义任务ID'), +}) + +export class KlingText2VideoRequestDto extends createZodDto(klingText2VideoRequestSchema) {} + +// Volcengine视频生成请求 +const volcengineGenerationRequestSchema = z.object({ + model: z.string().describe('模型ID或Endpoint ID'), + content: z.array(z.union([ + z.object({ + type: z.literal(ContentType.Text), + text: z.string(), + }), + z.object({ + type: z.literal(ContentType.ImageUrl), + image_url: z.object({ + url: z.string(), + }), + role: z.enum(ImageRole).optional(), + }), + ])).describe('输入内容'), + return_last_frame: z.boolean().optional().describe('是否返回尾帧图像'), +}) + +export class VolcengineGenerationRequestDto extends createZodDto(volcengineGenerationRequestSchema) {} + +// 图生视频请求DTO +const klingImage2VideoRequestSchema = z.object({ + model_name: z.string().min(1).describe('模型名称'), + image: z.string().optional().describe('参考图像'), + image_tail: z.string().optional().describe('参考图像 - 尾帧控制'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + cfg_scale: z.number().optional().describe('生成视频的自由度'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + static_mask: z.string().optional().describe('静态笔刷涂抹区域'), + dynamic_masks: z.array(z.any()).optional().describe('动态笔刷配置列表'), + camera_control: z.any().optional().describe('控制摄像机运动的协议'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), + external_task_id: z.string().optional().describe('自定义任务ID'), +}) + +export class KlingImage2VideoRequestDto extends createZodDto(klingImage2VideoRequestSchema) {} + +// 多图生视频请求DTO +const klingMultiImage2VideoRequestSchema = z.object({ + model_name: z.string().min(1).describe('模型名称'), + image_list: z.array(z.any()).describe('图片列表'), + prompt: z.string().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + mode: z.enum(Mode).optional().describe('生成视频的模式'), + duration: z.enum(['5', '10']).optional().describe('生成视频时长'), + aspect_ratio: z.enum(AspectRatio).optional().describe('生成图片的画面纵横比'), + external_task_id: z.string().optional().describe('自定义任务ID'), +}) + +export class KlingMultiImage2VideoRequestDto extends createZodDto(klingMultiImage2VideoRequestSchema) {} + +// Kling任务查询DTO +const klingTaskQuerySchema = z.object({ + taskId: z.string().min(1).describe('任务ID'), +}) + +export class KlingTaskQueryDto extends createZodDto(klingTaskQuerySchema) {} + +// Volcengine任务查询DTO +const volcengineTaskQuerySchema = z.object({ + taskId: z.string().min(1).describe('任务ID'), +}) + +export class VolcengineTaskQueryDto extends createZodDto(volcengineTaskQuerySchema) {} + +// Dashscope 文生视频请求 +const dashscopeText2VideoRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + input: z.object({ + prompt: z.string().min(1).describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + }), + parameters: z.object({ + size: z.string().optional().describe('视频尺寸'), + duration: z.number().optional().describe('视频时长(秒)'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeText2VideoRequestDto extends createZodDto(dashscopeText2VideoRequestSchema) {} + +// Dashscope 图生视频请求 +const dashscopeImage2VideoRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + input: z.object({ + image_url: z.string().min(1).describe('图片URL'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + }), + parameters: z.object({ + resolution: z.string().optional().describe('分辨率'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeImage2VideoRequestDto extends createZodDto(dashscopeImage2VideoRequestSchema) {} + +// Dashscope 首尾帧生视频请求 +const dashscopeKeyFrame2VideoRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + input: z.object({ + first_frame_url: z.string().min(1).describe('首帧图片URL'), + last_frame_url: z.string().optional().describe('尾帧图片URL'), + prompt: z.string().optional().describe('正向文本提示词'), + negative_prompt: z.string().optional().describe('负向文本提示词'), + template: z.string().optional().describe('模板'), + }), + parameters: z.object({ + resolution: z.string().optional().describe('分辨率'), + duration: z.number().optional().describe('视频时长(秒)'), + prompt_extend: z.boolean().optional().describe('是否扩展提示词'), + }).optional(), +}) + +export class DashscopeKeyFrame2VideoRequestDto extends createZodDto(dashscopeKeyFrame2VideoRequestSchema) {} + +// Dashscope 任务查询 DTO +const dashscopeTaskQuerySchema = z.object({ + taskId: z.string().min(1).describe('任务ID'), +}) + +export class DashscopeTaskQueryDto extends createZodDto(dashscopeTaskQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/index.ts new file mode 100644 index 000000000..d038a0362 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/dto/index.ts @@ -0,0 +1,4 @@ +export * from './ai-chat.dto' +export * from './ai-image.dto' +export * from './ai-logs.dto' +export * from './ai-video.dto' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.config.ts new file mode 100644 index 000000000..fc50da87f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.config.ts @@ -0,0 +1,9 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const dashscopeConfigSchema = z.object({ + apiKey: z.string().describe('DashScope API Key'), + baseUrl: z.string().default('https://dashscope.aliyuncs.com').describe('DashScope Base URL'), +}) + +export class DashscopeConfig extends createZodDto(dashscopeConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.interface.ts new file mode 100644 index 000000000..b170fbffa --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.interface.ts @@ -0,0 +1,186 @@ +// 任务状态枚举 +export enum TaskStatus { + Pending = 'PENDING', + Running = 'RUNNING', + Succeeded = 'SUCCEEDED', + Failed = 'FAILED', + Canceled = 'CANCELED', + Unknown = 'UNKNOWN', +} + +// 支持的模型 +export type DashscopeVideoModel + = | 'wan2.2-i2v-flash' + | 'wan2.2-i2v-plus' + | 'wanx2.1-i2v-plus' + | 'wanx2.1-i2v-turbo' + | 'wan2.2-kf2v-flash' + | 'wanx2.1-kf2v-plus' + | 'wan2.2-t2v-plus' + | 'wanx2.1-t2v-turbo' + | 'wanx2.1-t2v-plus' + | string + +// 支持的分辨率 +export type Resolution = '480P' | '720P' | '1080P' | string + +// 支持的视频尺寸 +export type VideoSize + = | '1920*1080' + | '1280*720' + | '854*480' + | '720*1280' + | '480*854' + | '1080*1920' + | string + +// 错误信息接口 +export interface TaskError { + /** 错误码 */ + code: string + /** 错误消息 */ + message: string + /** 请求ID */ + request_id?: string +} + +// 图生视频输入 +export interface ImageToVideoInput { + image_url: string + prompt?: string + negative_prompt?: string +} + +// 首尾帧生视频输入 +export interface KeyFrameToVideoInput { + first_frame_url: string + last_frame_url?: string + prompt?: string + negative_prompt?: string + template?: string +} + +// 文生视频输入 +export interface TextToVideoInput { + prompt: string + negative_prompt?: string +} + +// 图生视频参数配置 +export interface ImageToVideoParameters { + /** 分辨率 */ + resolution?: Resolution + /** 是否扩展提示词 */ + prompt_extend?: boolean +} + +// 首尾帧生视频参数配置 +export interface KeyFrameToVideoParameters { + /** 分辨率 */ + resolution?: Resolution + /** 是否扩展提示词 */ + prompt_extend?: boolean + /** 视频时长(秒) */ + duration?: number +} + +// 文生视频参数配置 +export interface TextToVideoParameters { + /** 视频尺寸 */ + size?: VideoSize + /** 是否扩展提示词 */ + prompt_extend?: boolean + /** 视频时长(秒) */ + duration?: number +} + +// 创建视频生成任务响应接口 +export interface CreateVideoTaskResponse { + /** HTTP状态码 */ + status_code: number + /** 请求ID */ + request_id: string + /** 错误码 */ + code: string | null + /** 错误消息 */ + message: string + /** 输出结果 */ + output: { + /** 任务ID */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 视频URL(仅在任务完成时返回) */ + video_url?: string + } + /** 使用量统计(仅在任务完成时返回) */ + usage?: { + /** 视频数量 */ + video_count: number + /** 视频时长(秒) */ + video_duration: number + /** 视频分辨率 */ + video_ratio: string + } | null +} + +// 查询视频生成任务响应接口 +export interface GetVideoTaskResponse { + /** HTTP状态码 */ + status_code: number + /** 请求ID */ + request_id: string + /** 错误码 */ + code: string | null + /** 错误消息 */ + message: string + /** 输出结果 */ + output: { + /** 任务ID */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 视频URL(仅在任务完成时返回) */ + video_url?: string + /** 任务提交时间 */ + submit_time?: string + /** 任务调度时间 */ + scheduled_time?: string + /** 任务结束时间 */ + end_time?: string + /** 原始提示词 */ + orig_prompt?: string + /** 实际使用的提示词 */ + actual_prompt?: string + } + /** 使用量统计(仅在任务完成时返回) */ + usage?: { + /** 视频数量 */ + video_count: number + /** 视频时长(秒) */ + video_duration: number + /** 视频分辨率 */ + video_ratio: string + } | null +} + +// 图生视频请求 +export interface ImageToVideoRequest { + model: DashscopeVideoModel + input: ImageToVideoInput + parameters?: ImageToVideoParameters +} + +// 首尾帧生视频请求 +export interface KeyFrameToVideoRequest { + model: DashscopeVideoModel + input: KeyFrameToVideoInput + parameters?: KeyFrameToVideoParameters +} + +// 文生视频请求 +export interface TextToVideoRequest { + model: DashscopeVideoModel + input: TextToVideoInput + parameters?: TextToVideoParameters +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.module.ts new file mode 100644 index 000000000..1c9b4fb1b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { DashscopeConfig } from './dashscope.config' +import { DashscopeService } from './dashscope.service' + +@Module({}) +export class DashscopeModule { + static forRoot(config: DashscopeConfig): DynamicModule { + return { + global: true, + module: DashscopeModule, + providers: [ + { + provide: DashscopeConfig, + useValue: config, + }, + DashscopeService, + ], + exports: [DashscopeService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.service.ts new file mode 100644 index 000000000..1a5472c44 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/dashscope.service.ts @@ -0,0 +1,88 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosInstance, AxiosResponse } from 'axios' +import { DashscopeConfig } from './dashscope.config' +import { + CreateVideoTaskResponse, + GetVideoTaskResponse, + ImageToVideoRequest, + KeyFrameToVideoRequest, + TextToVideoRequest, +} from './dashscope.interface' + +@Injectable() +export class DashscopeService { + private readonly logger = new Logger(DashscopeService.name) + private readonly httpClient: AxiosInstance + + constructor(private readonly config: DashscopeConfig) { + this.httpClient = this._createHttpClient() + } + + /** + * 创建HTTP客户端 + */ + private _createHttpClient(): AxiosInstance { + return axios.create({ + baseURL: this.config.baseUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}`, + 'X-DashScope-Async': 'enable', + }, + }) + } + + /** + * 创建图生视频任务 + * POST https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis + */ + async createImageToVideoTask( + request: ImageToVideoRequest, + ): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/api/v1/services/aigc/video-generation/video-synthesis', + request, + ) + return response.data + } + + /** + * 创建首尾帧生视频任务 + * POST https://dashscope.aliyuncs.com/api/v1/services/aigc/image2video/video-synthesis + */ + async createKeyFrameToVideoTask( + request: KeyFrameToVideoRequest, + ): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/api/v1/services/aigc/image2video/video-synthesis', + request, + ) + return response.data + } + + /** + * 创建文生视频任务 + * POST https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis + */ + async createTextToVideoTask( + request: TextToVideoRequest, + ): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/api/v1/services/aigc/video-generation/video-synthesis', + request, + ) + return response.data + } + + /** + * 查询视频生成任务 + * GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id} + */ + async getVideoTask(taskId: string): Promise { + const response: AxiosResponse = await this.httpClient.get( + `/api/v1/tasks/${taskId}`, + ) + return response.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/index.ts new file mode 100644 index 000000000..fd90b1118 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/dashscope/index.ts @@ -0,0 +1,4 @@ +export * from './dashscope.config' +export * from './dashscope.interface' +export * from './dashscope.module' +export * from './dashscope.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.config.ts new file mode 100644 index 000000000..1fb1d2a33 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.config.ts @@ -0,0 +1,8 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const fireflycardConfigSchema = z.object({ + apiUrl: z.string().describe('Fireflycard API URL'), +}) + +export class FireflycardConfig extends createZodDto(fireflycardConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.module.ts new file mode 100644 index 000000000..aa69fa685 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.module.ts @@ -0,0 +1,20 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { FireflycardConfig } from './fireflycard.config' +import { FireflycardService } from './fireflycard.service' + +@Module({}) +export class FireflycardModule { + static forRoot(config: FireflycardConfig): DynamicModule { + return { + module: FireflycardModule, + providers: [ + { + provide: FireflycardConfig, + useValue: config, + }, + FireflycardService, + ], + exports: [FireflycardService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.service.ts new file mode 100644 index 000000000..6f68f44cb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/fireflycard.service.ts @@ -0,0 +1,131 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { FireflycardConfig } from './fireflycard.config' + +export enum FireflycardTempTypes { + A = 'tempA', // 默认 + B = 'tempB', // 书摘 + C = 'tempC', // 透明 + Jin = 'tempJin', // 金句 + Memo = 'tempMemo', // 备忘录 + Easy = 'tempEasy', // 便当 + BlackSun = 'tempBlackSun', // 黑日 + E = 'tempE', // 框界 + Write = 'tempWrite', // 手写 + Code = 'code', // 代码 + D = 'tempD', // 图片(暂时不用) +} + +export interface FireflycardOptions { + content: string + temp: FireflycardTempTypes + title?: string + style?: { + align?: string + backgroundName?: string + backShadow?: string + font?: string + width?: number + ratio?: string + height?: number + fontScale?: number + padding?: string + borderRadius?: string + color?: string + opacity?: number + blur?: number + backgroundAngle?: string + lineHeights?: { + content?: string + } + letterSpacings?: { + content?: string + } + } + switchConfig?: { + showIcon?: boolean + showDate?: boolean + showTitle?: boolean + showContent?: boolean + showAuthor?: boolean + showTextCount?: boolean + showQRCode?: boolean + showPageNum?: boolean + showWatermark?: boolean + } +} + +@Injectable() +export class FireflycardService { + private readonly logger = new Logger(FireflycardService.name) + + constructor(private readonly config: FireflycardConfig) {} + + /** + * 生成流光卡片图片 + */ + async createImage(options: FireflycardOptions): Promise { + const { content, temp, title = '' } = options + + const defaultStyle = { + align: 'left', + backgroundName: 'vertical-blue-color-29', + backShadow: '', + font: 'Alibaba-PuHuiTi-Regular', + width: 440, + ratio: '', + height: 0, + fontScale: 1, + padding: '30px', + borderRadius: '15px', + color: '#000000', + opacity: 1, + blur: 0, + backgroundAngle: '0deg', + lineHeights: { + content: '', + }, + letterSpacings: { + content: '', + }, + } + + const defaultSwitchConfig = { + showIcon: false, + showDate: true, + showTitle: !!title, + showContent: true, + showAuthor: false, + showTextCount: false, + showQRCode: false, + showPageNum: false, + showWatermark: false, + } + + const body = { + form: { + title, + content, + pagination: '01', + }, + style: { ...defaultStyle, ...options.style }, + switchConfig: { ...defaultSwitchConfig, ...options.switchConfig }, + temp, + language: 'zh', + } + + const response = await fetch(this.config.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + if (!response.ok) { + throw new AppException(ResponseCode.AiCallFailed) + } + + return response + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/index.ts new file mode 100644 index 000000000..f3542005e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/fireflycard/index.ts @@ -0,0 +1,3 @@ +export * from './fireflycard.config' +export * from './fireflycard.module' +export * from './fireflycard.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/index.ts new file mode 100644 index 000000000..9f5cd3026 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/index.ts @@ -0,0 +1,4 @@ +export * from './kling.config' +export * from './kling.interface' +export * from './kling.module' +export * from './kling.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.config.ts new file mode 100644 index 000000000..8dc416a1b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const klingConfigSchema = z.object({ + baseUrl: z.string().default('https://api-beijing.klingai.com').describe('Kling Base URL'), + accessKey: z.string().describe('Kling Access Key'), + secretKey: z.string().optional().describe('Kling Secret Key'), +}) + +export class KlingConfig extends createZodDto(klingConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.interface.ts new file mode 100644 index 000000000..ca23a6b2c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.interface.ts @@ -0,0 +1,745 @@ +// 通用响应类型 +export interface KlingResponse { + /** 错误码;具体定义见错误码 */ + code: number + /** 错误信息 */ + message: string + /** 请求ID,系统生成,用于跟踪请求、排查问题 */ + request_id: string + /** 响应数据 */ + data: T +} + +// 任务状态枚举 +export enum TaskStatus { + /** 已提交 */ + Submitted = 'submitted', + /** 处理中 */ + Processing = 'processing', + /** 成功 */ + Succeed = 'succeed', + /** 失败 */ + Failed = 'failed', +} + +// 模式枚举 +export enum Mode { + /** 标准模式 */ + Std = 'std', + /** 专家模式 */ + Pro = 'pro', +} + +// 视频时长枚举 +export type Duration = '5' | '10' | string + +// 画面纵横比枚举 +export enum AspectRatio { + /** 16:9 */ + SixteenNine = '16:9', + /** 9:16 */ + NineSixteen = '9:16', + /** 1:1 */ + OneOne = '1:1', +} + +// 运镜类型枚举 +export enum CameraControlType { + /** 简单运镜 */ + Simple = 'simple', + /** 镜头下压并后退 */ + DownBack = 'down_back', + /** 镜头前进并上仰 */ + ForwardUp = 'forward_up', + /** 先右旋转后前进 */ + RightTurnForward = 'right_turn_forward', + /** 先左旋并前进 */ + LeftTurnForward = 'left_turn_forward', +} + +// 编辑模式枚举 +export enum EditMode { + /** 增加元素 */ + Addition = 'addition', + /** 替换元素 */ + Swap = 'swap', + /** 删除元素 */ + Removal = 'removal', +} + +// 对口型模式枚举 +export enum LipSyncMode { + /** 文本生成视频模式 */ + Text2Video = 'text2video', + /** 音频生成视频模式 */ + Audio2Video = 'audio2video', +} + +// 音色语种枚举 +export enum VoiceLanguage { + /** 中文 */ + Zh = 'zh', + /** 英文 */ + En = 'en', +} + +// 音频类型枚举 +export enum AudioType { + /** 上传文件模式 */ + File = 'file', + /** 提供下载链接模式 */ + Url = 'url', +} + +// 文生视频模型名称 +export type Text2VideoModel = 'kling-v1' | 'kling-v1-6' | 'kling-v2-master' | 'kling-v2-1-master' | string + +// 图生视频模型名称 +export type Image2VideoModel = 'kling-v1' | 'kling-v1-5' | 'kling-v1-6' | 'kling-v2-master' | 'kling-v2-1' | 'kling-v2-1-master' | string + +// 多图生视频模型名称 +export type MultiImage2VideoModel = 'kling-v1-6' | string + +// 多模态视频编辑模型名称 +export type MultiElementsModel = 'kling-v1-6' | string + +// 对口型模型名称 +export type LipSyncModel = 'kling-v1-6' | string + +// 视频特效模型名称 +export type VideoEffectsSingleImageModel = 'kling-v1-6' | string +export type VideoEffectsInteractionModel = 'kling-v1' | 'kling-v1-5' | 'kling-v1-6' | string + +// 视频延长模型名称 +export type VideoExtendModel = 'kling-v1' | 'kling-v1-5' | 'kling-v1-6' | string + +// 运镜配置接口 +export interface CameraControlConfig { + /** 水平运镜,控制摄像机在水平方向上的移动量(沿x轴平移) */ + horizontal?: number + /** 垂直运镜,控制摄像机在垂直方向上的移动量(沿y轴平移) */ + vertical?: number + /** 水平摇镜,控制摄像机在水平面上的旋转量(绕y轴旋转) */ + pan?: number + /** 垂直摇镜,控制摄像机在垂直面上的旋转量(沿x轴旋转) */ + tilt?: number + /** 旋转运镜,控制摄像机的滚动量(绕z轴旋转) */ + roll?: number + /** 变焦,控制摄像机的焦距变化,影响视野的远近 */ + zoom?: number +} + +// 运镜控制接口 +export interface CameraControl { + /** 预定义的运镜类型 */ + type?: CameraControlType + /** 运镜配置 */ + config?: CameraControlConfig +} + +// 文生视频创建任务请求接口 +export interface Text2VideoCreateTaskRequest { + /** 模型名称 */ + model_name?: Text2VideoModel + /** 正向文本提示词 */ + prompt: string + /** 负向文本提示词 */ + negative_prompt?: string + /** 生成视频的自由度 */ + cfg_scale?: number + /** 生成视频的模式 */ + mode?: Mode + /** 控制摄像机运动的协议 */ + camera_control?: CameraControl + /** 生成视频的画面纵横比 */ + aspect_ratio?: AspectRatio + /** 生成视频时长 */ + duration?: Duration + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 视频结果接口 +export interface VideoResult { + /** 生成的视频ID;全局唯一 */ + id: string + /** 生成视频的URL */ + url: string + /** 视频总时长,单位s */ + duration: string +} + +// 任务结果接口 +export interface TaskResult { + /** 视频列表 */ + videos: VideoResult[] +} + +// 任务信息接口 +export interface TaskInfo { + /** 客户自定义任务ID */ + external_task_id: string +} + +// 文生视频创建任务响应数据接口 +export interface Text2VideoCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 文生视频查询任务响应数据接口(单个) +export interface Text2VideoGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 文生视频查询任务列表响应数据接口 +export type Text2VideoGetTasksResponseData = Text2VideoGetTaskResponseData[] + +// 动态笔刷轨迹点接口 +export interface TrajectoryPoint { + /** 轨迹点横坐标 */ + x?: number + /** 轨迹点纵坐标 */ + y?: number +} + +// 动态笔刷配置接口 +export interface DynamicMask { + /** 动态笔刷涂抹区域 */ + mask?: string + /** 运动轨迹坐标序列 */ + trajectories?: TrajectoryPoint[] +} + +// 图生视频创建任务请求接口 +export interface Image2VideoCreateTaskRequest { + /** 模型名称 */ + model_name?: Image2VideoModel + /** 参考图像 */ + image?: string + /** 参考图像 - 尾帧控制 */ + image_tail?: string + /** 正向文本提示词 */ + prompt?: string + /** 负向文本提示词 */ + negative_prompt?: string + /** 生成视频的自由度 */ + cfg_scale?: number + /** 生成视频的模式 */ + mode?: Mode + /** 静态笔刷涂抹区域 */ + static_mask?: string + /** 动态笔刷配置列表 */ + dynamic_masks?: DynamicMask[] + /** 控制摄像机运动的协议 */ + camera_control?: CameraControl + /** 生成视频时长 */ + duration?: Duration + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 图生视频创建任务响应数据接口 +export interface Image2VideoCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 图生视频查询任务响应数据接口(单个) +export interface Image2VideoGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 图生视频查询任务列表响应数据接口 +export type Image2VideoGetTasksResponseData = Image2VideoGetTaskResponseData[] + +// 图片列表项接口 +export interface ImageListItem { + /** 图片URL */ + image: string +} + +// 多图生视频创建任务请求接口 +export interface MultiImage2VideoCreateTaskRequest { + /** 模型名称 */ + model_name?: MultiImage2VideoModel + /** 图片列表 */ + image_list: ImageListItem[] + /** 正向文本提示词 */ + prompt: string + /** 负向文本提示词 */ + negative_prompt?: string + /** 生成视频的模式 */ + mode?: Mode + /** 生成视频时长 */ + duration?: Duration + /** 生成图片的画面纵横比 */ + aspect_ratio?: AspectRatio + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 多图生视频创建任务响应数据接口 +export interface MultiImage2VideoCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 多图生视频查询任务响应数据接口(单个) +export interface MultiImage2VideoGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 对口型输入接口 +export interface LipSyncInput { + /** 通过可灵AI生成的视频的ID */ + video_id?: string + /** 所上传视频的获取链接 */ + video_url?: string + /** 生成视频的模式 */ + mode: LipSyncMode + /** 生成对口型视频的文本内容 */ + text?: string + /** 音色ID */ + voice_id?: string + /** 音色语种 */ + voice_language?: VoiceLanguage + /** 语速 */ + voice_speed?: number + /** 使用音频文件生成对口型视频时,传输音频文件的方式 */ + audio_type?: AudioType + /** 音频文件本地路径 */ + audio_file?: string + /** 音频文件下载url */ + audio_url?: string +} + +// 对口型创建任务请求接口 +export interface LipSyncCreateTaskRequest { + /** 输入参数 */ + input: LipSyncInput + /** 本次任务结果回调通知地址 */ + callback_url?: string +} + +// 对口型父视频接口 +export interface ParentVideo { + /** 原视频ID;全局唯一 */ + id: string + /** 原视频的URL */ + url: string + /** 原视频总时长,单位s */ + duration: string +} + +// 对口型任务信息接口 +export interface LipSyncTaskInfo { + /** 父视频信息 */ + parent_video: ParentVideo +} + +// 对口型创建任务响应数据接口 +export interface LipSyncCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 视频特效单图输入接口 +export interface VideoEffectsSingleImageInput { + /** 模型名称 */ + model_name: VideoEffectsSingleImageModel + /** 参考图像 */ + image: string + /** 生成视频时长 */ + duration: Duration +} + +// 视频特效双人互动输入接口 +export interface VideoEffectsInteractionInput { + /** 模型名称 */ + model_name?: VideoEffectsInteractionModel + /** 生成视频的模式 */ + mode?: Mode + /** 参考图像组 */ + images: string[] + /** 生成视频时长 */ + duration: Duration +} + +// 视频特效创建任务请求接口 +export interface VideoEffectsCreateTaskRequest { + /** 场景名称 */ + effect_scene: string + /** 输入参数 */ + input: VideoEffectsSingleImageInput | VideoEffectsInteractionInput + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 视频特效创建任务响应数据接口 +export interface VideoEffectsCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 视频特效查询任务响应数据接口(单个) +export interface VideoEffectsGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 视频特效查询任务列表响应数据接口 +export type VideoEffectsGetTasksResponseData = VideoEffectsGetTaskResponseData[] + +// 对口型查询任务响应数据接口(单个) +export interface LipSyncGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: LipSyncTaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 对口型查询任务列表响应数据接口 +export type LipSyncGetTasksResponseData = LipSyncGetTaskResponseData[] + +// 多图生视频查询任务列表响应数据接口 +export type MultiImage2VideoGetTasksResponseData = MultiImage2VideoGetTaskResponseData[] + +// 多模态视频编辑初始化待编辑视频请求接口 +export interface MultiElementsInitSelectionRequest { + /** 视频ID */ + video_id?: string + /** 获取视频的URL */ + video_url?: string +} + +// 多模态视频编辑初始化待编辑视频响应数据接口 +export interface MultiElementsInitSelectionResponseData { + /** 拒识码,非0为识别失败 */ + status: number + /** 会话ID */ + session_id: string + /** 解析后视频的帧数 */ + fps?: number + /** 解析后视频的时长 */ + original_duration?: number + /** 解析后视频的宽 */ + width?: number + /** 解析后视频的高 */ + height?: number + /** 解析后视频的总帧数 */ + total_frame?: number + /** 初始化后的视频URL */ + normalized_video?: string +} + +// 多模态视频编辑增加视频选区请求接口 +export interface MultiElementsAddSelectionRequest { + /** 会话ID */ + session_id: string + /** 帧号 */ + frame_index: number + /** 点选坐标 */ + points: TrajectoryPoint[] +} + +// PNG掩码接口 +export interface PngMask { + /** 尺寸 */ + size: [number, number] + /** Base64编码 */ + base64: string +} + +// RLE掩码接口 +export interface RleMask { + /** 尺寸 */ + size: [number, number] + /** 编码 */ + counts: string +} + +// 掩码列表项接口 +export interface MaskListItem { + /** 对象ID */ + object_id: number + /** RLE掩码 */ + rle_mask: RleMask + /** PNG掩码 */ + png_mask: PngMask +} + +// 多模态视频编辑增加/删减视频选区响应数据接口 +export interface MultiElementsSelectionResponseData { + /** 拒识码,非0为识别失败 */ + status: number + /** 会话ID */ + session_id: string + /** 结果 */ + res: { + /** 帧号 */ + frame_index: number + /** 掩码列表 */ + rle_mask_list: MaskListItem[] + } +} + +// 多模态视频编辑删减视频选区请求接口 +export interface MultiElementsDeleteSelectionRequest { + /** 会话ID */ + session_id: string + /** 帧号 */ + frame_index: number + /** 点选坐标 */ + points: TrajectoryPoint[] +} + +// 多模态视频编辑清除视频选区请求接口 +export interface MultiElementsClearSelectionRequest { + /** 会话ID */ + session_id: string +} + +// 多模态视频编辑预览已选区视频请求接口 +export interface MultiElementsPreviewSelectionRequest { + /** 会话ID */ + session_id: string +} + +// 多模态视频编辑预览已选区视频响应数据接口 +export interface MultiElementsPreviewSelectionResponseData { + /** 拒识码,非0为识别失败 */ + status: number + /** 会话ID */ + session_id: string + /** 结果 */ + res: { + /** 含mask的视频 */ + video: string + /** 含mask的视频的封面 */ + video_cover: string + /** 图像分割结果中,每一帧mask结果 */ + tracking_output: string + } +} + +// 多模态视频编辑创建任务请求接口 +export interface MultiElementsCreateTaskRequest { + /** 模型名称 */ + model_name?: MultiElementsModel + /** 会话ID */ + session_id: string + /** 操作类型 */ + edit_mode: EditMode + /** 裁剪后的参考图像 */ + image_list?: ImageListItem[] + /** 正向文本提示词 */ + prompt: string + /** 负向文本提示词 */ + negative_prompt?: string + /** 生成视频的模式 */ + mode?: Mode + /** 生成视频时长 */ + duration?: Duration + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 多模态视频编辑创建任务响应数据接口 +export interface MultiElementsCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 会话ID */ + session_id: string + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 多模态视频编辑查询任务响应数据接口(单个) +export interface MultiElementsGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 多模态视频编辑查询任务列表响应数据接口 +export type MultiElementsGetTasksResponseData = MultiElementsGetTaskResponseData[] + +// ==================== 视频延长相关接口 ==================== + +// 视频延长创建任务请求接口 +export interface VideoExtendCreateTaskRequest { + /** 模型名称 */ + model_name?: VideoExtendModel + /** 视频ID */ + video_id?: string + /** 视频URL */ + video_url?: string + /** 延长时长,单位秒 */ + extend_duration: Duration + /** 生成视频的模式 */ + mode?: Mode + /** 本次任务结果回调通知地址 */ + callback_url?: string + /** 自定义任务ID */ + external_task_id?: string +} + +// 视频延长创建任务响应数据接口 +export interface VideoExtendCreateTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务状态 */ + task_status: TaskStatus + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 视频延长查询任务响应数据接口(单个) +export interface VideoExtendGetTaskResponseData { + /** 任务ID,系统生成 */ + task_id: string + /** 任务状态 */ + task_status: TaskStatus + /** 任务状态信息,当任务失败时展示失败原因 */ + task_status_msg: string + /** 任务信息 */ + task_info: TaskInfo + /** 任务结果 */ + task_result: TaskResult + /** 任务创建时间,Unix时间戳、单位ms */ + created_at: number + /** 任务更新时间,Unix时间戳、单位ms */ + updated_at: number +} + +// 视频延长查询任务列表响应数据接口 +export type VideoExtendGetTasksResponseData = VideoExtendGetTaskResponseData[] diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.module.ts new file mode 100644 index 000000000..af6d4e0a0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { KlingConfig } from './kling.config' +import { KlingService } from './kling.service' + +@Module({}) +export class KlingModule { + static forRoot(config: KlingConfig): DynamicModule { + return { + global: true, + module: KlingModule, + providers: [ + { + provide: KlingConfig, + useValue: config, + }, + KlingService, + ], + exports: [KlingService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.service.ts new file mode 100644 index 000000000..22ec0cefb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/kling/kling.service.ts @@ -0,0 +1,540 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import axios, { AxiosInstance, AxiosResponse } from 'axios' +import jwt from 'jsonwebtoken' +import { KlingConfig } from './kling.config' +import { + // 图生视频相关类型 + Image2VideoCreateTaskRequest, + + Image2VideoCreateTaskResponseData, + Image2VideoGetTaskResponseData, + Image2VideoGetTasksResponseData, + // 通用类型 + KlingResponse, + + // 对口型相关类型 + LipSyncCreateTaskRequest, + LipSyncCreateTaskResponseData, + LipSyncGetTaskResponseData, + LipSyncGetTasksResponseData, + + MultiElementsAddSelectionRequest, + MultiElementsClearSelectionRequest, + MultiElementsCreateTaskRequest, + MultiElementsCreateTaskResponseData, + + MultiElementsDeleteSelectionRequest, + MultiElementsGetTaskResponseData, + MultiElementsGetTasksResponseData, + // 多模态视频编辑相关类型 + MultiElementsInitSelectionRequest, + MultiElementsInitSelectionResponseData, + MultiElementsPreviewSelectionRequest, + MultiElementsPreviewSelectionResponseData, + MultiElementsSelectionResponseData, + // 多图生视频相关类型 + MultiImage2VideoCreateTaskRequest, + MultiImage2VideoCreateTaskResponseData, + MultiImage2VideoGetTaskResponseData, + MultiImage2VideoGetTasksResponseData, + + // 文生视频相关类型 + Text2VideoCreateTaskRequest, + Text2VideoCreateTaskResponseData, + Text2VideoGetTaskResponseData, + Text2VideoGetTasksResponseData, + + // 视频特效相关类型 + VideoEffectsCreateTaskRequest, + VideoEffectsCreateTaskResponseData, + VideoEffectsGetTaskResponseData, + VideoEffectsGetTasksResponseData, + + // 视频延长相关类型 + VideoExtendCreateTaskRequest, + VideoExtendCreateTaskResponseData, + VideoExtendGetTaskResponseData, + VideoExtendGetTasksResponseData, +} from './kling.interface' + +@Injectable() +export class KlingService { + private readonly logger = new Logger(KlingService.name) + + private readonly httpClient: AxiosInstance + + constructor(private readonly config: KlingConfig) { + this.httpClient = axios.create({ + timeout: 30000, + baseURL: config.baseUrl, + }) + + // 添加请求拦截器 + this.httpClient.interceptors.request.use((config) => { + let token + if (this.config.secretKey) { + const now = Math.floor(Date.now() / 1000) + token = jwt.sign( + { + iss: this.config.accessKey, + exp: now + 1800, + nbf: now - 5, + }, + this.config.secretKey, + { + algorithm: 'HS256', + }, + ) + } + else { + token = this.config.accessKey + } + this.logger.debug({ + token, + }) + config.headers.Authorization = `Bearer ${token}` + config.headers['Content-Type'] = 'application/json' + return config + }) + + const resInterceptor = (response: AxiosResponse) => { + this.logger.debug({ + data: response.data, + }) + const klingResponse = response.data as KlingResponse + if (klingResponse.code !== 0) { + this.logger.error({ + data: response.data, + msg: '可灵 api 调用失败', + }) + throw new AppException(ResponseCode.AiCallFailed, klingResponse.message) + } + return response + } + this.httpClient.interceptors.response.use(resInterceptor) + } + + // ==================== 文生视频相关方法 ==================== + + /** + * 创建文生视频任务 + * POST /v1/videos/text2video + */ + async createText2VideoTask( + request: Text2VideoCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/text2video`, + request, + ) + + return response.data + } + + /** + * 查询文生视频任务(单个) + * GET /v1/videos/text2video/{id} + */ + async getText2VideoTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/text2video/${taskId}`, + ) + + return response.data + } + + /** + * 查询文生视频任务列表 + * GET /v1/videos/text2video + */ + async getText2VideoTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/text2video?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 图生视频相关方法 ==================== + + /** + * 创建图生视频任务 + * POST /v1/videos/image2video + */ + async createImage2VideoTask( + request: Image2VideoCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/image2video`, + request, + ) + + return response.data + } + + /** + * 查询图生视频任务(单个) + * GET /v1/videos/image2video/{id} + */ + async getImage2VideoTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/image2video/${taskId}`, + ) + + return response.data + } + + /** + * 查询图生视频任务列表 + * GET /v1/videos/image2video + */ + async getImage2VideoTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/image2video?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 多图生视频相关方法 ==================== + + /** + * 创建多图生视频任务 + * POST /v1/videos/multi-image2video + */ + async createMultiImage2VideoTask( + request: MultiImage2VideoCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-image2video`, + request, + ) + + return response.data + } + + /** + * 查询多图生视频任务(单个) + * GET /v1/videos/multi-image2video/{id} + */ + async getMultiImage2VideoTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/multi-image2video/${taskId}`, + ) + + return response.data + } + + /** + * 查询多图生视频任务列表 + * GET /v1/videos/multi-image2video + */ + async getMultiImage2VideoTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/multi-image2video?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 多模态视频编辑相关方法 ==================== + + /** + * 初始化待编辑视频 + * POST /v1/videos/multi-elements/init-selection + */ + async initMultiElementsSelection( + request: MultiElementsInitSelectionRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements/init-selection`, + request, + ) + + return response.data + } + + /** + * 增加视频选区 + * POST /v1/videos/multi-elements/add-selection + */ + async addMultiElementsSelection( + request: MultiElementsAddSelectionRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements/add-selection`, + request, + ) + + return response.data + } + + /** + * 删减视频选区 + * POST /v1/videos/multi-elements/delete-selection + */ + async deleteMultiElementsSelection( + request: MultiElementsDeleteSelectionRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements/delete-selection`, + request, + ) + + return response.data + } + + /** + * 清除视频选区 + * POST /v1/videos/multi-elements/clear-selection + */ + async clearMultiElementsSelection( + request: MultiElementsClearSelectionRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements/clear-selection`, + request, + ) + + return response.data + } + + /** + * 预览已选区视频 + * POST /v1/videos/multi-elements/preview-selection + */ + async previewMultiElementsSelection( + request: MultiElementsPreviewSelectionRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements/preview-selection`, + request, + ) + + return response.data + } + + /** + * 创建多模态视频编辑任务 + * POST /v1/videos/multi-elements + */ + async createMultiElementsTask( + request: MultiElementsCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/multi-elements`, + request, + ) + + return response.data + } + + /** + * 查询多模态视频编辑任务(单个) + * GET /v1/videos/multi-elements/{id} + */ + async getMultiElementsTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/multi-elements/${taskId}`, + ) + + return response.data + } + + /** + * 查询多模态视频编辑任务列表 + * GET /v1/videos/multi-elements + */ + async getMultiElementsTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/multi-elements?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 对口型相关方法 ==================== + + /** + * 创建对口型任务 + * POST /v1/videos/lip-sync + */ + async createLipSyncTask( + request: LipSyncCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/lip-sync`, + request, + ) + + return response.data + } + + /** + * 查询对口型任务(单个) + * GET /v1/videos/lip-sync/{id} + */ + async getLipSyncTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/lip-sync/${taskId}`, + ) + + return response.data + } + + /** + * 查询对口型任务列表 + * GET /v1/videos/lip-sync + */ + async getLipSyncTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/lip-sync?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 视频特效相关方法 ==================== + + /** + * 创建视频特效任务 + * POST /v1/videos/effects + */ + async createVideoEffectsTask( + request: VideoEffectsCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/effects`, + request, + ) + + return response.data + } + + /** + * 查询视频特效任务(单个) + * GET /v1/videos/effects/{id} + */ + async getVideoEffectsTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/effects/${taskId}`, + ) + + return response.data + } + + /** + * 查询视频特效任务列表 + * GET /v1/videos/effects + */ + async getVideoEffectsTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/effects?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } + + // ==================== 视频延长相关方法 ==================== + + /** + * 创建视频延长任务 + * POST /v1/videos/video-extend + */ + async createVideoExtendTask( + request: VideoExtendCreateTaskRequest, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.post( + `/v1/videos/video-extend`, + request, + ) + + return response.data + } + + /** + * 查询视频延长任务(单个) + * GET /v1/videos/video-extend/{id} + */ + async getVideoExtendTask( + taskId: string, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/video-extend/${taskId}`, + ) + + return response.data + } + + /** + * 查询视频延长任务列表 + * GET /v1/videos/video-extend + */ + async getVideoExtendTasks( + pageNum = 1, + pageSize = 30, + ): Promise> { + const response: AxiosResponse> + = await this.httpClient.get( + `/v1/videos/video-extend?pageNum=${pageNum}&pageSize=${pageSize}`, + ) + + return response.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/index.ts new file mode 100644 index 000000000..b30886225 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/index.ts @@ -0,0 +1,4 @@ +export * from './md2card.config' +export * from './md2card.interface' +export * from './md2card.module' +export * from './md2card.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.config.ts new file mode 100644 index 000000000..0f8e75627 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const md2cardConfigSchema = z.object({ + apiKey: z.string().describe('MD2Card API Key'), + baseUrl: z.string().default('https://md2card.cn').describe('MD2Card API URL'), + timeout: z.number().default(60000).describe('API请求超时时间(毫秒)'), +}) + +export class Md2cardConfig extends createZodDto(md2cardConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.interface.ts new file mode 100644 index 000000000..094c2be31 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.interface.ts @@ -0,0 +1,17 @@ +export interface GenerateCardParams { + markdown: string + theme?: string + themeMode?: string + width?: number + height?: number + splitMode?: string + mdxMode?: boolean + overHiddenMode?: boolean +} + +export interface GenerateCardResult { + images: { + url: string + fileName: string + }[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.module.ts new file mode 100644 index 000000000..d08209072 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.module.ts @@ -0,0 +1,20 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { Md2cardConfig } from './md2card.config' +import { Md2cardService } from './md2card.service' + +@Module({}) +export class Md2cardModule { + static forRoot(config: Md2cardConfig): DynamicModule { + return { + module: Md2cardModule, + providers: [ + { + provide: Md2cardConfig, + useValue: config, + }, + Md2cardService, + ], + exports: [Md2cardService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.service.ts new file mode 100644 index 000000000..61f8efb6d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/md2card/md2card.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import axios from 'axios' +import { Md2cardConfig } from './md2card.config' +import { GenerateCardParams, GenerateCardResult } from './md2card.interface' + +@Injectable() +export class Md2cardService { + private readonly logger = new Logger(Md2cardService.name) + private readonly baseUrl: string + private readonly apiKey: string + private readonly timeout: number + + constructor(private readonly config: Md2cardConfig) { + this.baseUrl = config.baseUrl + this.apiKey = config.apiKey + this.timeout = config.timeout + } + + async generateCard(params: GenerateCardParams): Promise { + this.logger.debug(`调用 MD2Card API: ${this.baseUrl}/api/generate`) + + try { + const response = await axios.post( + `${this.baseUrl}/api/generate`, + params, + { + headers: { + 'x-api-key': this.apiKey, + 'Content-Type': 'application/json', + }, + timeout: this.timeout, + }, + ) + + return response.data + } + catch (error: any) { + this.logger.error(`MD2Card API 调用失败: ${error.message}`) + throw new AppException(ResponseCode.AiCallFailed) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/index.ts new file mode 100644 index 000000000..84a27e647 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/index.ts @@ -0,0 +1,3 @@ +export * from './openai.config' +export * from './openai.module' +export * from './openai.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.config.ts new file mode 100644 index 000000000..ba23ea1b7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const openaiConfigSchema = z.object({ + apiKey: z.string().describe('OpenAI API Key'), + baseUrl: z.string().default('https://api.openai.com/v1').describe('OpenAI Base URL'), + timeout: z.number().default(300 * 1000), +}) + +export class OpenaiConfig extends createZodDto(openaiConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.module.ts new file mode 100644 index 000000000..e07f111b6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { OpenaiConfig } from './openai.config' +import { OpenaiService } from './openai.service' + +@Module({}) +export class OpenaiModule { + static forRoot(config: OpenaiConfig): DynamicModule { + return { + global: true, + module: OpenaiModule, + providers: [ + { + provide: OpenaiConfig, + useValue: config, + }, + OpenaiService, + ], + exports: [OpenaiService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.service.ts new file mode 100644 index 000000000..0eb9928f9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/openai/openai.service.ts @@ -0,0 +1,91 @@ +import { AIMessageChunk, BaseMessage } from '@langchain/core/messages' +import { ChatOpenAI, OpenAIChatInput } from '@langchain/openai' +import { Injectable, Logger } from '@nestjs/common' +import OpenAI from 'openai' +import { OpenaiConfig } from './openai.config' + +@Injectable() +export class OpenaiService { + private readonly logger = new Logger(OpenaiService.name) + private readonly openAI: OpenAI + private readonly chatOpenAI: ChatOpenAI + + constructor(private readonly config: OpenaiConfig) { + this.openAI = this._createOpenAIClient() + this.chatOpenAI = this._createChatModel({}) + } + + private _createOpenAIClient(apiKey?: string): OpenAI { + return new OpenAI({ + apiKey: apiKey ?? this.config.apiKey, + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + }) + } + + private _createChatModel(options: Partial & { + apiKey?: string + }): ChatOpenAI { + return new ChatOpenAI({ + ...options, + maxRetries: 1, + timeout: options.timeout ?? this.config.timeout, + apiKey: options.apiKey ?? this.config.apiKey, + configuration: { + baseURL: this.config.baseUrl, + }, + streaming: true, + }) + } + + async createChatCompletionStream(options: Partial & { + model: string + messages: BaseMessage[] + }) { + const { + messages, + } = options + + const chatModel = this._createChatModel(options) + return await chatModel.stream(messages, options) + } + + async createChatCompletion(options: Partial & { + model: string + messages: BaseMessage[] + }): Promise { + const stream = await this.createChatCompletionStream(options) + let result: AIMessageChunk | undefined + + for await (const chunk of stream) { + if (result) { + result = result.concat(chunk) + } + else { + result = chunk + } + } + + this.logger.debug(`usage_metadata: ${JSON.stringify(result!.usage_metadata, null, 2)}`) + + return result! + } + + async createImageGeneration(options: Omit & { apiKey?: string }): Promise { + const { apiKey, ...imageParams } = options + const client = this._createOpenAIClient(apiKey) + return client.images.generate(imageParams) + } + + async createImageEdit(options: Omit & { apiKey?: string }): Promise { + const { apiKey, ...editParams } = options + const client = this._createOpenAIClient(apiKey) + return client.images.edit(editParams) + } + + async createImageVariation(options: Omit & { apiKey?: string }): Promise { + const { apiKey, ...variationParams } = options + const client = this._createOpenAIClient(apiKey) + return client.images.createVariation(variationParams) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/index.ts new file mode 100644 index 000000000..a87c198e3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/index.ts @@ -0,0 +1,4 @@ +export * from './sora2.config' +export * from './sora2.interface' +export * from './sora2.module' +export * from './sora2.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.config.ts new file mode 100644 index 000000000..0872254b0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.config.ts @@ -0,0 +1,9 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const sora2ConfigSchema = z.object({ + apiKey: z.string().describe('Sora2 API Key'), + baseUrl: z.string().default('https://api.aicso.top').describe('Sora2 Base URL'), +}) + +export class Sora2Config extends createZodDto(sora2ConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.interface.ts new file mode 100644 index 000000000..dca846f3e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.interface.ts @@ -0,0 +1,52 @@ +// 任务状态枚举 +export enum TaskStatus { + Pending = 'pending', + Running = 'running', + Cancelled = 'cancelled', + Completed = 'completed', + Failed = 'failed', +} + +// 图片角色枚举 +export enum VideoOrientation { + Portrait = 'portrait', + Landscape = 'landscape', +} + +// 图片角色枚举 +export enum VideoSize { + Large = 'large', + Small = 'small', +} + +// 创建视频生成任务请求接口 +export interface CreateVideoGenerationTaskRequest { + model: string + images?: string[] + orientation: VideoOrientation + prompt: string + size: VideoSize + duration: 10 | 15 +} + +// 创建视频生成任务响应接口 +export interface CreateVideoGenerationTaskResponse { + id: string + status: TaskStatus +} + +// 查询视频生成任务响应接口 +export interface GetVideoGenerationTaskResponse { + id: string + status: TaskStatus + video_url?: string + thumbnail_url?: string + status_update_time: number + finish_reason?: string +} + +// 支持的模型 +export type VideoModel + = | 'sora2' + | 'sora2-pro' + | string diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.module.ts new file mode 100644 index 000000000..afc704205 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { Sora2Config } from './sora2.config' +import { Sora2Service } from './sora2.service' + +@Module({}) +export class Sora2Module { + static forRoot(config: Sora2Config): DynamicModule { + return { + global: true, + module: Sora2Module, + providers: [ + { + provide: Sora2Config, + useValue: config, + }, + Sora2Service, + ], + exports: [Sora2Service], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.service.ts new file mode 100644 index 000000000..c882027c0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/sora2/sora2.service.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosInstance, AxiosResponse } from 'axios' +import { Sora2Config } from './sora2.config' +import { + CreateVideoGenerationTaskRequest, + CreateVideoGenerationTaskResponse, + GetVideoGenerationTaskResponse, +} from './sora2.interface' + +@Injectable() +export class Sora2Service { + private readonly logger = new Logger(Sora2Service.name) + private readonly httpClient: AxiosInstance + + constructor(private readonly config: Sora2Config) { + this.httpClient = this._createHttpClient() + } + + /** + * 创建HTTP客户端 + */ + private _createHttpClient(): AxiosInstance { + return axios.create({ + baseURL: this.config.baseUrl, + timeout: 300000, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}`, + }, + }) + } + + /** + * 创建视频生成任务 + */ + async createVideoGenerationTask( + request: CreateVideoGenerationTaskRequest, + ) { + const response: AxiosResponse = await this.httpClient.post( + '/v1/video/create', + request, + ) + + return response.data + } + + /** + * 查询视频生成任务 + */ + async getVideoGenerationTask( + taskId: string, + ) { + const response: AxiosResponse = await this.httpClient.get( + `/v1/video/query`, + { + params: { id: taskId }, + }, + ) + + return response.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/index.ts new file mode 100644 index 000000000..dcb782577 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/index.ts @@ -0,0 +1,5 @@ +export * from './volcengine.config' +export * from './volcengine.interface' +export * from './volcengine.module' +export * from './volcengine.service' +export * from './volcengine.utils' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.config.ts new file mode 100644 index 000000000..d6e2fe0ba --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.config.ts @@ -0,0 +1,9 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const volcengineConfigSchema = z.object({ + apiKey: z.string().describe('Volcengine API Key'), + baseUrl: z.string().default('https://ark.cn-beijing.volces.com').describe('Volcengine Base URL'), +}) + +export class VolcengineConfig extends createZodDto(volcengineConfigSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.interface.ts new file mode 100644 index 000000000..36b56487e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.interface.ts @@ -0,0 +1,146 @@ +// 任务状态枚举 +export enum TaskStatus { + Queued = 'queued', + Running = 'running', + Cancelled = 'cancelled', + Succeeded = 'succeeded', + Failed = 'failed', +} + +// 内容类型枚举 +export enum ContentType { + Text = 'text', + ImageUrl = 'image_url', +} + +// 图片角色枚举 +export enum ImageRole { + FirstFrame = 'first_frame', + LastFrame = 'last_frame', + ReferenceImage = 'reference_image', +} + +// 错误信息接口 +export interface TaskError { + /** 错误码 */ + code: string + /** 错误提示信息 */ + message: string +} + +// 图片URL接口 +export interface ImageUrl { + /** 图片信息,可以是图片URL或图片Base64编码。图片URL需确保可被访问;Base64编码格式:data:image/<图片格式>;base64, */ + url: string +} + +// 文本内容接口 +export interface TextContent { + /** 输入内容的类型,此处应为text */ + type: ContentType.Text + /** 输入给模型的文本内容,描述期望生成的视频。支持中英文,建议不超过500字。可在文本提示词后追加--[parameters]控制视频输出规格 */ + text: string +} + +// 图片内容接口 +export interface ImageContent { + /** 输入内容的类型,此处应为image_url */ + type: ContentType.ImageUrl + /** 输入给模型的图片对象 */ + image_url: ImageUrl + /** 图片的位置或用途。首帧图生视频可不填或为first_frame;首尾帧图生视频必填first_frame/last_frame;参考图生视频必填reference_image */ + role?: ImageRole +} + +// 内容联合类型 +export type Content = TextContent | ImageContent + +// 视频内容接口 +export interface VideoContent { + /** 生成视频的URL,格式为mp4。为保障信息安全,生成的视频会在24小时后被清理,请及时转存 */ + video_url: string + /** 视频的尾帧图像URL。有效期为24小时,请及时转存。说明:创建视频生成任务时设置"return_last_frame": true时,会返回参数 */ + last_frame_url?: string +} + +// 使用量统计接口 +export interface Usage { + /** 模型生成的token数量 */ + completion_tokens: number + /** 视频生成模型不统计输入token,输入token为0,故total_tokens=completion_tokens */ + total_tokens: number +} + +// 创建视频生成任务请求接口 +export interface CreateVideoGenerationTaskRequest { + /** 您需要调用的模型的ID(Model ID)或Endpoint ID */ + model: string + /** 输入给模型,生成视频的信息,支持文本信息和图片信息 */ + content: Content[] + /** 填写本次生成任务结果的回调通知地址。当视频生成任务有状态变化时,方舟将向此地址推送POST请求 */ + callback_url?: string + /** 是否返回生成视频的尾帧图像。仅doubao-seedance-1-0-lite-i2v支持该参数。默认值false */ + return_last_frame?: boolean +} + +// 创建视频生成任务响应接口 +export interface CreateVideoGenerationTaskResponse { + /** 视频生成任务ID。创建视频生成任务为异步接口,获取ID后,需要通过查询视频生成任务API来查询视频生成任务的状态 */ + id: string +} + +// 查询视频生成任务响应接口 +export interface GetVideoGenerationTaskResponse { + /** 视频生成任务ID */ + id: string + /** 任务使用的模型名称和版本,模型名称-版本 */ + model: string + /** 任务状态:queued(排队中)、running(任务运行中)、cancelled(取消任务,取消状态24h自动删除)、succeeded(任务成功)、failed(任务失败) */ + status: TaskStatus + /** 错误提示信息,任务成功返回null,任务失败时返回错误数据 */ + error: TaskError | null + /** 任务创建时间的Unix时间戳(秒) */ + created_at: number + /** 任务当前状态更新时间的Unix时间戳(秒) */ + updated_at: number + /** 当视频生成任务完成,会输出该字段,包含生成视频下载的URL */ + content?: VideoContent + /** 本次请求使用的种子整数值 */ + seed?: number + /** 生成视频的分辨率 */ + resolution?: string + /** 生成视频的宽高比 */ + ratio?: string + /** 生成视频的时长,单位:秒 */ + duration?: number + /** 生成视频的帧率 */ + framespersecond?: number + /** 本次请求的token用量 */ + usage?: Usage +} + +// 支持的分辨率 +export type Resolution = '480p' | '720p' | '1080p' | string + +// 支持的宽高比 +export type Ratio + = | '21:9' + | '16:9' + | '4:3' + | '1:1' + | '3:4' + | '9:16' + | '9:21' + | 'keep_ratio' + | 'adaptive' + | string + +// 支持的模型 +export type VideoModel + = | 'doubao-seedance-pro' + | 'doubao-seedance-1-0-lite-t2v' + | 'doubao-seedance-1-0-lite-i2v' + | 'wan2-1-14b-t2v' + | 'wan2-1-14b-i2v' + | 'wan2-1-14b-flf2v' + | string diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.module.ts new file mode 100644 index 000000000..558277ba6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { VolcengineConfig } from './volcengine.config' +import { VolcengineService } from './volcengine.service' + +@Module({}) +export class VolcengineModule { + static forRoot(config: VolcengineConfig): DynamicModule { + return { + global: true, + module: VolcengineModule, + providers: [ + { + provide: VolcengineConfig, + useValue: config, + }, + VolcengineService, + ], + exports: [VolcengineService], + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.service.ts new file mode 100644 index 000000000..1206330c0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from '@nestjs/common' +import axios, { AxiosInstance, AxiosResponse } from 'axios' +import { VolcengineConfig } from './volcengine.config' +import { + CreateVideoGenerationTaskRequest, + CreateVideoGenerationTaskResponse, + GetVideoGenerationTaskResponse, +} from './volcengine.interface' + +@Injectable() +export class VolcengineService { + private readonly logger = new Logger(VolcengineService.name) + private readonly httpClient: AxiosInstance + + constructor(private readonly config: VolcengineConfig) { + this.httpClient = this._createHttpClient() + } + + /** + * 创建HTTP客户端 + */ + private _createHttpClient(): AxiosInstance { + return axios.create({ + baseURL: this.config.baseUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}`, + }, + }) + } + + /** + * 创建视频生成任务 + * POST https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks + */ + async createVideoGenerationTask( + request: CreateVideoGenerationTaskRequest, + ) { + const response: AxiosResponse = await this.httpClient.post( + '/api/v3/contents/generations/tasks', + request, + ) + + return response.data + } + + /** + * 查询视频生成任务 + * GET https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id} + */ + async getVideoGenerationTask( + taskId: string, + ) { + const response: AxiosResponse = await this.httpClient.get( + `/api/v3/contents/generations/tasks/${taskId}`, + ) + + return response.data + } + + /** + * 取消或删除视频生成任务 + * DELETE https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{id} + */ + async deleteVideoGenerationTask( + taskId: string, + ) { + await this.httpClient.delete(`/api/v3/contents/generations/tasks/${taskId}`) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.utils.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.utils.ts new file mode 100644 index 000000000..72590ca66 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/libs/volcengine/volcengine.utils.ts @@ -0,0 +1,119 @@ +// 导入类型定义 +import type { Ratio, Resolution } from './volcengine.interface' + +/** + * 火山引擎视频生成模型文本命令工具类 + * 用于解析和序列化模型文本命令参数 + */ + +// 模型文本命令参数接口 +export interface ModelTextCommandParams { + /** 视频分辨率,简写 rs */ + resolution?: Resolution + /** 生成视频的宽高比例,简写 rt */ + ratio?: Ratio + /** 生成视频时长,单位:秒,简写 dur */ + duration?: number + /** 帧率,即一秒时间内视频画面数量,简写 fps */ + framespersecond?: number + /** 生成视频是否包含水印,简写 wm */ + watermark?: boolean + /** 种子整数,用于控制生成内容的随机性,简写 seed */ + seed?: number + /** 是否固定摄像头,简写 cf */ + camerafixed?: boolean +} + +// 参数映射表 +const PARAM_MAP = { + resolution: 'rs', + ratio: 'rt', + duration: 'dur', + framespersecond: 'fps', + watermark: 'wm', + seed: 'seed', + camerafixed: 'cf', +} as const + +// 反向映射表 +const REVERSE_PARAM_MAP = Object.fromEntries( + Object.entries(PARAM_MAP).map(([key, value]) => [value, key]), +) as Record + +/** + * 将模型文本命令参数序列化为字符串 + * @param params 模型文本命令参数对象 + * @returns 序列化后的命令字符串,如 "--rs 720p --rt 16:9 --dur 5" + */ +export function serializeModelTextCommand(params: ModelTextCommandParams): string { + const commands: string[] = [] + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + const shortKey = PARAM_MAP[key as keyof ModelTextCommandParams] + if (shortKey) { + commands.push(`--${shortKey} ${value}`) + } + } + } + + return commands.join(' ') +} + +/** + * 从文本中解析模型文本命令参数 + * @param text 包含命令参数的文本,如 "小猫对着镜头打哈欠。 --rs 720p --rt 16:9 --dur 5 --fps 24 --wm true --seed 11 --cf false" + * @returns 解析结果,包含纯文本内容和参数对象 + */ +export function parseModelTextCommand(text: string): { + prompt: string + params: ModelTextCommandParams +} { + // 查找命令参数的起始位置 + const commandMatch = text.match(/\s+--\w+/) + if (!commandMatch) { + return { + prompt: text.trim(), + params: {}, + } + } + + const commandStartIndex = commandMatch.index! + const prompt = text.substring(0, commandStartIndex).trim() + const commandText = text.substring(commandStartIndex).trim() + + // 解析命令参数 + const params: ModelTextCommandParams = {} + const paramRegex = /--([a-z]+)\s+(\S+)/gi + let match = paramRegex.exec(commandText) + + while (match !== null) { + const [, shortKey, value] = match + const fullKey = REVERSE_PARAM_MAP[shortKey] + + if (fullKey) { + // 根据参数类型转换值 + switch (fullKey) { + case 'duration': + case 'framespersecond': + case 'seed': + params[fullKey] = Number.parseInt(value, 10) + break + case 'watermark': + case 'camerafixed': + params[fullKey] = value.toLowerCase() === 'true' + break + case 'resolution': + case 'ratio': + params[fullKey] = value + break + } + } + match = paramRegex.exec(commandText) + } + + return { + prompt, + params, + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/index.ts new file mode 100644 index 000000000..8ded41f17 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/index.ts @@ -0,0 +1,2 @@ +export * from './scheduler.module' +export * from './video-task-status.scheduler' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/scheduler.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/scheduler.module.ts new file mode 100644 index 000000000..de163637b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/scheduler.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { ScheduleModule } from '@nestjs/schedule' +import { VideoModule } from '../core/video' +import { VideoTaskStatusScheduler } from './video-task-status.scheduler' + +@Module({ + imports: [ + ScheduleModule.forRoot(), + VideoModule, + ], + providers: [VideoTaskStatusScheduler], + exports: [], +}) +export class SchedulerModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/video-task-status.scheduler.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/video-task-status.scheduler.ts new file mode 100644 index 000000000..f613783fc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/ai/scheduler/video-task-status.scheduler.ts @@ -0,0 +1,74 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { AiLog, AiLogChannel, AiLogRepository, AiLogStatus, AiLogType } from '@yikart/mongodb' +import { VideoService } from '../core/video' +import { DashscopeService } from '../libs/dashscope' +import { Sora2Service } from '../libs/sora2' +import { VolcengineService } from '../libs/volcengine' + +@Injectable() +export class VideoTaskStatusScheduler { + private readonly logger = new Logger(VideoTaskStatusScheduler.name) + + constructor( + private readonly aiLogRepo: AiLogRepository, + private readonly videoService: VideoService, + private readonly dashscopeService: DashscopeService, + private readonly volcengineService: VolcengineService, + private readonly sora2Service: Sora2Service, + ) {} + + /** + * 每30秒检查一次正在生成中的视频任务状态 + */ + @Cron(CronExpression.EVERY_30_SECONDS) + async processVideoTaskStatus() { + this.logger.log('开始检查视频生成任务状态') + + const generatingTasks = await this.aiLogRepo.list({ + type: AiLogType.Video, + status: AiLogStatus.Generating, + }) + + if (generatingTasks.length === 0) { + return + } + + this.logger.log(`找到 ${generatingTasks.length} 个正在生成中的视频任务`) + + for (const task of generatingTasks) { + await this.processTask(task) + } + } + + /** + * 处理单个任务 + */ + private async processTask(task: AiLog) { + const taskId = task.taskId + if (!taskId) { + this.logger.warn(`任务 ${task.id} 缺少 taskId,跳过检查`) + return + } + const channel = task.channel + + if (channel === AiLogChannel.Kling) { + await this.videoService.getKlingTask(task.userId, task.userType, task.id) + } + else if (channel === AiLogChannel.Dashscope) { + const result = await this.dashscopeService.getVideoTask(taskId) + await this.videoService.dashscopeCallback(result) + } + else if (channel === AiLogChannel.Volcengine) { + const result = await this.volcengineService.getVideoGenerationTask(taskId) + await this.videoService.volcengineCallback(result) + } + else if (channel === AiLogChannel.Sora2) { + const result = await this.sora2Service.getVideoGenerationTask(taskId) + await this.videoService.sora2Callback(result) + } + else { + this.logger.warn(`任务 ${task.id} 未知的 channel: ${channel},跳过检查`) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.controller.ts new file mode 100644 index 000000000..0ba9cd57c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AliGreenService } from './ali-green.service' +import { ImageBodyDto, TextBodyDto, VideoBodyDto, VideoResultBodyDto } from './dto/ali-green.dto' + +@Controller('aliGreen') +export class AliGreenController { + constructor( + private readonly aliGreenService: AliGreenService, + ) {} + + // 文本审核 限制在2000字以内 + @Post('textGreen/') + textGreen(@GetToken() token: TokenInfo, @Body() data: TextBodyDto) { + return this.aliGreenService.textGreen(data, token) + } + + // 图片审核 限制频率 qps为100 每张图片大小限制为20M以内 + @Post('imgGreen/') + imgGreen(@GetToken() token: TokenInfo, @Body() data: ImageBodyDto) { + return this.aliGreenService.imgGreen(data, token) + } + + // 视频审核 限制视频大小 为500M以内 格式为mp4 flv + @Post('videoGreen/') + videoGreen(@GetToken() token: TokenInfo, @Body() data: VideoBodyDto) { + return this.aliGreenService.videoGreen(data, token) + } + + @Post('getVideoResult/') + getVideoResult(@GetToken() token: TokenInfo, @Body() data: VideoResultBodyDto) { + return this.aliGreenService.getVideoResult(data, token) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.module.ts new file mode 100644 index 000000000..b60f63991 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { AliGreenApiModule } from '@yikart/ali-green' +import { config } from '../config' +import { AliGreenController } from './ali-green.controller' +import { AliGreenService } from './ali-green.service' + +@Module({ + imports: [AliGreenApiModule.forRoot(config.aliGreen)], + controllers: [AliGreenController], + providers: [AliGreenService], +}) +export class AliGreenModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.service.ts new file mode 100644 index 000000000..833f41c70 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/ali-green.service.ts @@ -0,0 +1,42 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { TokenInfo } from '@yikart/aitoearn-auth' +import { AliGreenApiService } from '@yikart/ali-green' +import * as _ from 'lodash' +import { UserService } from '../user/user.service' +import { ImageBodyDto, TextBodyDto, VideoBodyDto, VideoResultBodyDto } from './dto/ali-green.dto' + +@Injectable() +export class AliGreenService { + constructor( + private readonly aliGreenApiService: AliGreenApiService, + private readonly userService: UserService, + + ) {} + + async AuthVip(token: TokenInfo) { + const { id } = token + const user = await this.userService.getUserInfoById(id) + if (_.isEmpty(user) || _.isEmpty(user.vipInfo)) + throw new UnauthorizedException('这是会员限定功能,请开通会员使用') + } + + async textGreen(data: TextBodyDto, token: TokenInfo) { + await this.AuthVip(token) + return this.aliGreenApiService.textGreen(data.content) + } + + async imgGreen(data: ImageBodyDto, token: TokenInfo) { + await this.AuthVip(token) + return this.aliGreenApiService.imgGreen(data.imageUrl) + } + + async videoGreen(data: VideoBodyDto, token: TokenInfo) { + await this.AuthVip(token) + return this.aliGreenApiService.videoGreen(data.url) + } + + async getVideoResult(data: VideoResultBodyDto, token: TokenInfo) { + await this.AuthVip(token) + return this.aliGreenApiService.getVideoResult(data.taskId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/comment.ts new file mode 100644 index 000000000..77c67be5f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/comment.ts @@ -0,0 +1,60 @@ +export interface PinterestApp { + id: string + secret: string + authBackHost: string + baseUrl: string +} + +export enum Country { + US = 'US', + CN = 'CN', + UK = 'UK', +} + +export enum Currency { + USD = 'USD', + UNK = 'UNK', +} + +export interface CreateAccountBody { + country: Country // 'US'; + currency: Currency // USD; + name: string // 广告账户名称; + owner_user_id?: string // 拥有者用户id +} + +export interface CreateBoardBody { + name: string // board名称; +} + +export interface CreatePinBody { + link: string // 点击链接; + title: string // 标题 + description: string // 描述 + dominant_color: string // RGB表示的颜色 主引脚颜色。十六进制数,例如“#6E7874”。 + alt_text: string + board_id: string // 此 Pin 所属的板块。 + media_source: MediaSource + media_id?: string + url?: string + items?: CreatePinBodyItem[] +} + +interface CreatePinBodyItem { + url: string + title?: string // + description?: string + link?: string +} + +interface MediaSource { + source_type: SourceType +} + +export interface SourceType { + multiple_image_base64: 'multiple_image_base64' // + image_base64: 'image_base64' // + multiple_image_url: 'multiple_image_url' // + image_url: 'image_url' // + video_id: 'video_id' // +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/dto/ali-green.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/dto/ali-green.dto.ts new file mode 100644 index 000000000..c6d15fddc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/aiGreen/dto/ali-green.dto.ts @@ -0,0 +1,40 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: nevin + * @Description: 用户 + */ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' +import { + IsString, +} from 'class-validator' + +export class TextBodyDto { + @ApiProperty({ title: '文本内容', required: true }) + @IsString({ message: '文本内容' }) + @Expose() + readonly content: string +} + +export class ImageBodyDto { + @ApiProperty({ title: '图片地址', required: true }) + @IsString({ message: '图片地址' }) + @Expose() + readonly imageUrl: string +} + +export class VideoBodyDto { + @ApiProperty({ title: '视频地址', required: true }) + @IsString({ message: '视频地址' }) + @Expose() + readonly url: string +} + +export class VideoResultBodyDto { + @ApiProperty({ title: '视频任务id', required: true }) + @IsString({ message: '视频任务id' }) + @Expose() + readonly taskId: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.controller.ts new file mode 100644 index 000000000..aa2a25def --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.controller.ts @@ -0,0 +1,33 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 应用配置 + */ +import { Controller, Get, Param } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Public } from '@yikart/aitoearn-auth' +import { AppConfigService } from './app-config.service' +import { GetAppConfigListDto } from './dto/app-config.dto' + +@ApiTags('应用配置') +@Controller('appConfigs') +export class AppConfigController { + constructor( + private readonly appConfigService: AppConfigService, + ) {} + + @ApiOperation({ + description: '获取配置', + summary: '获取配置', + }) + @Public() + @Get('/:appId') + async getAppConfigList( + @Param() param: GetAppConfigListDto, + ) { + const res = await this.appConfigService.getConfig(param.appId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.module.ts new file mode 100644 index 000000000..a566168c7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' +import { AppConfigController } from './app-config.controller' +import { AppConfigService } from './app-config.service' + +@Module({ + providers: [AppConfigService], + controllers: [AppConfigController], +}) +export class AppConfigModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.service.ts new file mode 100644 index 000000000..dc1644730 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/app-config.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { AppConfig, AppConfigRepository } from '@yikart/mongodb' + +@Injectable() +export class AppConfigService { + constructor( + private readonly appConfigRepository: AppConfigRepository, + ) {} + + async getConfig(appId: string): Promise> { + const configs = await this.appConfigRepository.listByAppIdWithEnabled(appId) + return configs + } + + async getConfigHistory(appId: string, key: string, limit = 10): Promise { + return await this.appConfigRepository.listByAppIdAndKey(appId, key, limit) + } + + async updateConfig( + appId: string, + key: string, + value: any, + description?: string, + metadata?: Record, + ): Promise { + return await this.appConfigRepository.upsertByAppIdAndKey( + appId, + key, + { + value, + description, + metadata, + enabled: true, + }, + ) + } + + async batchUpdateConfigs( + appId: string, + configs: Record, + ): Promise<{ success: boolean, updatedCount: number }> { + const configEntries = Object.entries(configs).map(([key, value]) => ({ + appId, + key, + value, + enabled: true, + })) + + const result = await this.appConfigRepository.bulkUpsert(configEntries) + return { + success: true, + updatedCount: result, + } + } + + async deleteConfig(appId: string, key: string): Promise { + const result = await this.appConfigRepository.deleteByAppIdAndKey(appId, key) + return result > 0 + } + + async getConfigList( + page: TableDto, + query: { + appId?: string + key?: string + }, + ) { + return await this.appConfigRepository.listWithPagination({ + page: page.pageNo, + pageSize: page.pageSize, + appId: query.appId, + key: query.key, + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/dto/app-config.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/dto/app-config.dto.ts new file mode 100644 index 000000000..267639726 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-configs/dto/app-config.dto.ts @@ -0,0 +1,9 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const getAppConfigListSchema = z.object({ + appId: z.string().min(1), +}) +export class GetAppConfigListDto extends createZodDto( + getAppConfigListSchema, +) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.controller.ts new file mode 100644 index 000000000..1cbae180a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Get, Post, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Public } from '@yikart/aitoearn-auth' +import { CheckVersionDto, QueryAppReleaseDto } from './app-release.dto' +import { AppReleaseService } from './app-release.service' +import { CheckVersionVo } from './app-release.vo' + +@ApiTags('App Release') +@Controller('app-release') +export class AppReleaseController { + constructor(private readonly appReleaseService: AppReleaseService) {} + + @ApiOperation({ summary: '检查版本更新' }) + @Post('check') + @Public() + async checkVersion(@Body() data: CheckVersionDto): Promise { + const result = await this.appReleaseService.checkVersion(data) + return CheckVersionVo.create(result) + } + + @ApiOperation({ summary: '获取最新版本' }) + @Get('latest') + @Public() + async getLatest(@Query() query: QueryAppReleaseDto) { + const result = await this.appReleaseService.getLatestAppRelease(query) + if (result == null) + return null + return result + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.dto.ts new file mode 100644 index 000000000..251c9de37 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.dto.ts @@ -0,0 +1,65 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import { AppPlatform } from '@yikart/mongodb' +import { z } from 'zod' + +// 版本链接 Schema +export const appReleaseLinksSchema = z.object({ + store: z.string().optional().describe('商店链接'), + direct: z.string().describe('直接下载链接'), +}) +// 检查版本 DTO Schema +const checkVersionDtoSchema = z.object({ + platform: z.enum(AppPlatform).describe('平台'), + currentVersion: z.string().describe('当前版本号'), + currentBuildNumber: z.number().optional().describe('当前构建号'), +}) + +export class CheckVersionDto extends createZodDto(checkVersionDtoSchema) {} + +// 查询版本发布列表 DTO Schema(管理端) +const queryAppReleaseDtoSchema = z.object({ + platform: z.enum(AppPlatform).optional().describe('平台筛选'), + ...PaginationDtoSchema.shape, +}) + +export class QueryAppReleaseDto extends createZodDto(queryAppReleaseDtoSchema) {} + +// 创建版本发布 DTO Schema +const createAppReleaseDtoSchema = z.object({ + platform: z.enum(AppPlatform).describe('平台'), + version: z.string().describe('版本号'), + buildNumber: z.number().describe('构建号'), + forceUpdate: z.boolean().describe('是否强制更新'), + notes: z.string().describe('版本说明'), + links: appReleaseLinksSchema.describe('版本链接'), + publishedAt: z.iso.datetime().describe('发布时间'), +}) + +export class CreateAppReleaseDto extends createZodDto(createAppReleaseDtoSchema) {} + +// 更新版本发布 DTO Schema +const updateAppReleaseDtoSchema = z.object({ + platform: z.enum(AppPlatform).optional().describe('平台'), + version: z.string().optional().describe('版本号'), + buildNumber: z.number().optional().describe('构建号'), + forceUpdate: z.boolean().optional().describe('是否强制更新'), + notes: z.string().optional().describe('版本说明'), + links: appReleaseLinksSchema.optional().describe('版本链接'), + publishedAt: z.iso.datetime().optional().describe('发布时间'), +}) + +export class UpdateAppReleaseDto extends createZodDto(updateAppReleaseDtoSchema) {} + +// 获取版本发布详情 DTO Schema +const getAppReleaseByIdDtoSchema = z.object({ + id: z.string().describe('版本发布ID'), +}) + +export class GetAppReleaseByIdDto extends createZodDto(getAppReleaseByIdDtoSchema) {} + +// 删除版本发布 DTO Schema +const deleteAppReleaseDtoSchema = z.object({ + id: z.string().describe('版本发布ID'), +}) + +export class DeleteAppReleaseDto extends createZodDto(deleteAppReleaseDtoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.module.ts new file mode 100644 index 000000000..fd8caa311 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.module.ts @@ -0,0 +1,11 @@ +import { Global, Module } from '@nestjs/common' +import { AppReleaseController } from './app-release.controller' +import { AppReleaseService } from './app-release.service' + +@Global() +@Module({ + imports: [], + providers: [AppReleaseService], + controllers: [AppReleaseController], +}) +export class AppReleaseModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.service.ts new file mode 100644 index 000000000..33735403b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.service.ts @@ -0,0 +1,165 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { AppReleaseRepository } from '@yikart/mongodb' +import { CheckVersionDto, CreateAppReleaseDto, DeleteAppReleaseDto, GetAppReleaseByIdDto, QueryAppReleaseDto, UpdateAppReleaseDto } from './app-release.dto' + +@Injectable() +export class AppReleaseService { + private readonly logger = new Logger(AppReleaseService.name) + + constructor( + private readonly appReleaseRepo: AppReleaseRepository, + ) { } + + /** + * 创建版本发布 + */ + async create(data: CreateAppReleaseDto) { + // 检查是否存在相同平台和构建号的发布 + const exists = await this.appReleaseRepo.checkExistsByPlatformAndBuildNumber(data.platform, data.buildNumber) + + if (exists) { + throw new AppException(ResponseCode.AppReleaseAlreadyExists) + } + + return await this.appReleaseRepo.create({ + ...data, + publishedAt: new Date(data.publishedAt), + }) + } + + /** + * 更新版本发布 + */ + async update(id: string, data: UpdateAppReleaseDto) { + const existing = await this.appReleaseRepo.getById(id) + if (!existing) { + throw new AppException(ResponseCode.AppReleaseNotFound) + } + + // 如果更新了平台或构建号,检查是否冲突 + if (data.platform || data.buildNumber) { + const conflict = await this.appReleaseRepo.checkExistsByPlatformAndBuildNumber( + data.platform || existing.platform, + data.buildNumber || existing.buildNumber, + id, + ) + + if (conflict) { + throw new AppException(ResponseCode.AppReleaseAlreadyExists) + } + } + + return await this.appReleaseRepo.updateById(id, data) + } + + /** + * 删除版本发布 + */ + async delete(data: DeleteAppReleaseDto): Promise { + const existing = await this.appReleaseRepo.getById(data.id) + if (!existing) { + throw new AppException(ResponseCode.AppReleaseNotFound) + } + + await this.appReleaseRepo.deleteById(data.id) + } + + /** + * 获取版本发布详情 + */ + async findById(data: GetAppReleaseByIdDto) { + const release = await this.appReleaseRepo.getById(data.id) + if (!release) { + throw new AppException(ResponseCode.AppReleaseNotFound) + } + return release + } + + /** + * 查询版本发布列表(带分页) + */ + async findAll(query: QueryAppReleaseDto) { + return await this.appReleaseRepo.listWithPagination(query) + } + + /** + * 检查版本更新(客户端使用) + */ + async checkVersion(data: CheckVersionDto) { + // 获取该平台的最新版本 + const latestRelease = await this.appReleaseRepo.getLatestByPlatform(data.platform) + + if (!latestRelease) { + throw new AppException(ResponseCode.AppReleaseNotFound) + } + + // 获取最新的强制更新版本 + const latestForceUpdateRelease = await this.appReleaseRepo.getLatestByPlatform(data.platform, { forceUpdate: true }) + + // 优先比较 buildNumber,如果没有传入则比较 version + let hasUpdate: boolean + let forceUpdate = false + + if (data.currentBuildNumber !== undefined) { + // 使用 buildNumber 比较 + hasUpdate = data.currentBuildNumber < latestRelease.buildNumber + + // 如果存在强制更新版本,且当前版本低于强制更新版本,则触发强制更新 + if (latestForceUpdateRelease && data.currentBuildNumber < latestForceUpdateRelease.buildNumber) { + forceUpdate = true + } + } + else { + // 使用 version 比较 + hasUpdate = this.compareVersion(data.currentVersion, latestRelease.version) < 0 + + // 如果存在强制更新版本,且当前版本低于强制更新版本,则触发强制更新 + if (latestForceUpdateRelease && this.compareVersion(data.currentVersion, latestForceUpdateRelease.version) < 0) { + forceUpdate = true + } + } + + return { + hasUpdate, + forceUpdate, + latestVersion: latestRelease.version, + latestBuildNumber: latestRelease.buildNumber, + currentVersion: data.currentVersion, + currentBuildNumber: data.currentBuildNumber ?? 0, + notes: latestRelease.notes, + links: latestRelease.links, + publishedAt: latestRelease.publishedAt, + } + } + + /** + * 比较版本号 + * @returns -1: v1 < v2, 0: v1 = v2, 1: v1 > v2 + */ + private compareVersion(v1: string, v2: string): number { + const parts1 = v1.split('.').map(Number) + const parts2 = v2.split('.').map(Number) + const maxLength = Math.max(parts1.length, parts2.length) + + for (let i = 0; i < maxLength; i++) { + const num1 = parts1[i] || 0 + const num2 = parts2[i] || 0 + + if (num1 < num2) + return -1 + if (num1 > num2) + return 1 + } + + return 0 + } + + async getLatestAppRelease(data: QueryAppReleaseDto) { + const [list] = await this.findAll({ ...data, page: 1, pageSize: 1 }) + if (list.length === 0) { + return null + } + return list[0] + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.vo.ts new file mode 100644 index 000000000..cd5383a2d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app-release/app-release.vo.ts @@ -0,0 +1,35 @@ +import { createZodDto } from '@yikart/common' +import { AppPlatform } from '@yikart/mongodb' +import { z } from 'zod' +import { appReleaseLinksSchema } from './app-release.dto' + +// 版本发布信息 VO Schema +const appReleaseVoSchema = z.object({ + id: z.string().describe('版本发布ID'), + platform: z.enum(AppPlatform).describe('平台'), + version: z.string().describe('版本号'), + buildNumber: z.number().describe('构建号'), + forceUpdate: z.boolean().describe('是否强制更新'), + notes: z.string().describe('版本说明'), + links: appReleaseLinksSchema.describe('版本链接'), + publishedAt: z.iso.datetime().describe('发布时间'), + createdAt: z.date().optional().describe('创建时间'), + updatedAt: z.date().optional().describe('更新时间'), +}) + +export class AppReleaseVo extends createZodDto(appReleaseVoSchema) {} + +// 版本检查结果 VO Schema +const checkVersionVoSchema = z.object({ + hasUpdate: z.boolean().describe('是否有更新'), + forceUpdate: z.boolean().describe('是否强制更新'), + latestVersion: z.string().optional().describe('最新版本号'), + currentVersion: z.string().describe('当前版本号'), + latestBuildNumber: z.number().describe('构建号'), + currentBuildNumber: z.number().describe('构建号'), + notes: z.string().optional().describe('版本说明'), + links: appReleaseLinksSchema.optional().describe('版本链接'), + publishedAt: z.date().optional().describe('发布时间'), +}) + +export class CheckVersionVo extends createZodDto(checkVersionVoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/app.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/app.module.ts new file mode 100644 index 000000000..77707f051 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/app.module.ts @@ -0,0 +1,63 @@ +import path from 'node:path' +import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { AitoearnAuthModule } from '@yikart/aitoearn-auth' +import { AitoearnQueueModule } from '@yikart/aitoearn-queue' +import { MailModule } from '@yikart/mail' +import { MongodbModule } from '@yikart/mongodb' +import { AccountModule } from './account/account.module' +import { LogsModule } from './ai/core/logs' +import { AppConfigModule } from './app-configs/app-config.module' +import { AppReleaseModule } from './app-release/app-release.module' +import { ChannelModule } from './channel/channel.module' +import { config } from './config' +import { ContentModule } from './content/content.module' +import { FeedbackModule } from './feedback/feedback.module' +import { FileModule } from './file/file.module' +import { IncomeModule } from './income/income.module' +import { InternalModule } from './internal/internal.module' +import { ManagerModule } from './manager/manager.module' +import { NotificationModule } from './notification/notification.module' +import { PublishModule } from './publishRecord/publishRecord.module' +import { StatisticsModule } from './statistics/statistics.module' +import { TaskModule } from './task/task.module' +import { TransportsModule } from './transports/transports.module' +import { UserModule } from './user/user.module' + +@Module({ + imports: [ + EventEmitterModule.forRoot(), + MongodbModule.forRoot(config.mongodb), + AitoearnQueueModule.forRoot({ + redis: config.redis, + prefix: '{bull}', + }), + MailModule.forRoot({ + ...config.mail, + template: { + dir: path.join(__dirname, 'views'), + }, + }), + AitoearnAuthModule.forRoot(config.auth), + FileModule, + LogsModule, + TransportsModule, + AppConfigModule, + FeedbackModule, + NotificationModule, + AppReleaseModule, + AccountModule, + UserModule, + ContentModule, + ChannelModule, + TaskModule, + IncomeModule, + StatisticsModule, + PublishModule, + InternalModule, + ManagerModule, + ], + controllers: [], + providers: [], +}) +export class AppModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.common.ts new file mode 100644 index 000000000..dec83d027 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.common.ts @@ -0,0 +1,49 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface BClient { + clientName: string + clientId: string + clientSecret: string + authBackUrl: string +} + +export enum NoReprint { + No = 1, + Yes = 0, +} + +export enum Copyright { + Original = 1, // 原创 + Reprint = 2, +} + +export interface BilibiliPublishOption { + tid: number // 分区ID,由获取分区信息接口得到 + no_reprint?: NoReprint // 是否允许转载 0-允许,1-不允许。默认0 + copyright: Copyright // 1-原创,2-转载(转载时source必填) + source?: string // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number // 参加的话题ID,默认情况下不填写,需要填写和运营联系 +} + +export type AddArchiveData = { + title: string // 标题 + cover?: string // 封面url + desc?: string // 描述 +} & BilibiliPublishOption + +export enum ArchiveStatus { + all = 'all', + is_pubing = 'is_pubing', + pubed = 'pubed', + not_pubed = 'not_pubed', +} + +export interface AccessToken { + access_token: string // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number // 1630220614; + refresh_token: string // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[] // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.controller.ts new file mode 100644 index 000000000..721988e6a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.controller.ts @@ -0,0 +1,127 @@ +import { + Controller, + Get, + Param, + Post, + Query, + Render, + UseGuards, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatBilibiliNatsApi } from '../../transports/channel/api/bilibili.natsApi' +import { BilibiliService } from './bilibili.service' +import { + GetArchiveListDto, + GetArcStatDto, +} from './dto/bilibili.dto' + +@ApiTags('plat/bilibili - B站平台') +@Controller('plat/bilibili') +export class BilibiliController { + constructor( + private readonly bilibiliService: BilibiliService, + private readonly platBilibiliApi: PlatBilibiliNatsApi, + ) {} + + @Public() + @UseGuards(OrgGuard) + @Get('auth/back/:taskId') + @Render('auth/back') + async getAccessToken( + @Param('taskId') taskId: string, + @Query() + query: { + code: string + state: string + }, + ) { + const res = await this.platBilibiliApi.createAccountAndSetAccessToken({ + taskId, + ...query, + }) + return res + } + + @ApiOperation({ summary: '获取页面的认证URL' }) + @Get('auth/url/:type') + async getAuthUrl( + @GetToken() token: TokenInfo, + @Param('type') type: 'h5' | 'pc', + @Query('spaceId') spaceId?: string, + ) { + const res = await this.platBilibiliApi.getAuth(token.id, type, spaceId || '') + return res + } + + @ApiOperation({ summary: '获取账号授权状态回调' }) + @Post('auth/create-account/:taskId') + async getAuthInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + return this.platBilibiliApi.getAuthInfo(taskId) + } + + @ApiOperation({ summary: '获取账号授权状态回调' }) + @Get('auth/status/:accountId') + async checkAccountAuthStatus( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return await this.bilibiliService.checkAccountAuthStatus(accountId) + } + + @ApiOperation({ summary: '分区查询' }) + @Get('archive/type/list/:accountId') + async archiveTypeList( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.platBilibiliApi.archiveTypeList(accountId) + } + + @ApiOperation({ summary: '获取稿件列表' }) + @Get('archive/list/:pageNo/:pageSize') + async getArchiveList( + @GetToken() token: TokenInfo, + @Param() page: TableDto, + @Query() query: GetArchiveListDto, + ) { + return this.platBilibiliApi.getArchiveList(query.accountId, page, { + status: query.status, + }) + } + + @ApiOperation({ summary: '获取用户数据' }) + @Get('stat/user/:accountId') + async getUserStat( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.platBilibiliApi.getUserStat(accountId) + } + + @ApiOperation({ summary: '获取稿件数据' }) + @Get('stat/arc') + async getArcStat( + @GetToken() token: TokenInfo, + @Query() query: GetArcStatDto, + ) { + return this.platBilibiliApi.getArcStat( + query.accountId, + query.resourceId, + ) + } + + @ApiOperation({ summary: '获取稿件增量数据数据' }) + @Get('stat/inc/arc/:accountId') + async getArcIncStat( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.platBilibiliApi.getArcIncStat(accountId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.module.ts new file mode 100644 index 000000000..119e1465b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { BilibiliController } from './bilibili.controller' +import { BilibiliService } from './bilibili.service' + +@Module({ + imports: [], + controllers: [BilibiliController], + providers: [BilibiliService], + exports: [BilibiliService], +}) +export class BilibiliModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.service.ts new file mode 100644 index 000000000..394223f45 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/bilibili.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common' +import { PlatBilibiliNatsApi } from '../../transports/channel/api/bilibili.natsApi' + +@Injectable() +export class BilibiliService { + constructor(private readonly platBilibiliNatsApi: PlatBilibiliNatsApi) {} + + /** + * 检查登陆状态是否过期 + * @param accountId + * @param file File + * @returns + */ + async checkAccountAuthStatus(accountId: string) { + const res = await this.platBilibiliNatsApi.getAccountAuthInfo(accountId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/comment.ts new file mode 100644 index 000000000..c734799b3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/comment.ts @@ -0,0 +1,23 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface BClient { + clientName: string + clientId: string + clientSecret: string + authBackUrl: string +} + +export interface AddArchiveData { + title: string // 标题 + cover?: string // 封面url + tid: number // 分区ID,由获取分区信息接口得到 + no_reprint?: 0 | 1 // 是否允许转载 0-允许,1-不允许。默认0 + desc?: string // 描述 + tag: string // 标签, 多个标签用英文逗号分隔,总长度小于200 + copyright: 1 | 2 // 1-原创,2-转载(转载时source必填) + source?: string // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number // 参加的话题ID,默认情况下不填写,需要填写和运营联系 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/dto/bilibili.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/dto/bilibili.dto.ts new file mode 100644 index 000000000..3398083ee --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/bilibili/dto/bilibili.dto.ts @@ -0,0 +1,24 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { ArchiveStatus } from '../bilibili.common' + +export const AccountIdSchema = z.object({ + accountId: z.string().describe('账号ID'), +}) +export class AccountIdDto extends createZodDto(AccountIdSchema) {} + +export const AccessBackSchema = z.object({ + code: z.string().describe('code'), + state: z.string().describe('state'), +}) +export class AccessBackDto extends createZodDto(AccessBackSchema) {} + +const GetArchiveListSchema = AccountIdSchema.extend({ + status: z.enum(ArchiveStatus).optional(), +}) +export class GetArchiveListDto extends createZodDto(GetArchiveListSchema) {} + +const GetArcStatSchema = AccountIdSchema.extend({ + resourceId: z.string({ message: '稿件ID' }), +}) +export class GetArcStatDto extends createZodDto(GetArcStatSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.controller.ts new file mode 100644 index 000000000..a4894af92 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.controller.ts @@ -0,0 +1,9 @@ +import { Controller } from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' +import { ChannelService } from './channel.service' + +@ApiTags('频道') +@Controller('channel') +export class ChannelController { + constructor(private readonly taskService: ChannelService) { } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.module.ts new file mode 100644 index 000000000..f3645fd24 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.module.ts @@ -0,0 +1,56 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { PublishModule } from '../publishRecord/publishRecord.module' +import { PostModule } from '../statistics/post/post.module' +import { TaskModule } from '../task/task.module' +import { ChannelApiModule } from '../transports/channel/channelApi.module' +import { BilibiliModule } from './bilibili/bilibili.module' +import { ChannelController } from './channel.controller' +import { ChannelService } from './channel.service' +import { DataCubeModule } from './dataCube/dataCube.module' +import { EngagementController } from './engagement/engagement.controller' +import { EngagementModule } from './engagement/engagement.module' +import { InteractController } from './interact/interact.controller' +import { InteractModule } from './interact/interact.module' +import { KwaiModule } from './kwai/kwai.module' +import { MetaModule } from './meta/meta.module' +import { PinterestModule } from './pinterest/pinterest.module' +import { PublishController } from './publish.controller' +import { PublishService } from './publish.service' +import { SkKeyModule } from './skKey/skKey.module' +import { TiktokModule } from './tiktok/tiktok.module' +import { TwitterModule } from './twitter/twitter.module' +import { WxGzhModule } from './wxGzh/wxGzh.module' +import { YoutubeModule } from './youtube/youtube.module' + +@Global() +@Module({ + imports: [ + HttpModule, + ChannelApiModule, + InteractModule, + DataCubeModule, + SkKeyModule, + BilibiliModule, + WxGzhModule, + YoutubeModule, + KwaiModule, + TiktokModule, + PinterestModule, + TwitterModule, + MetaModule, + EngagementModule, + TaskModule, + PostModule, + PublishModule, + ], + providers: [ChannelService, PublishService], + controllers: [ + PublishController, + InteractController, + EngagementController, + ChannelController, + ], + exports: [ChannelService], +}) +export class ChannelModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.service.ts new file mode 100644 index 000000000..6c3ad1bd4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/channel.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common' +import { AccountStatus } from '@yikart/mongodb' +import { ChannelApi } from '../transports/channel/channel.api' + +@Injectable() +export class ChannelService { + constructor( + private readonly channelApi: ChannelApi, + ) { } + + /** + * 获取用户账号列表 + * @param userId + */ + async getUserAccounts(userId: string) { + const res = await this.channelApi.getUserAccounts( + { userId }, + ) + return res + } + + async updateChannelAccountStatus(accountId: string, status: AccountStatus) { + const res = await this.channelApi.updateChannelAccountStatus( + { accountId, status }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/common.ts new file mode 100644 index 000000000..0dd8f0fdf --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/common.ts @@ -0,0 +1,68 @@ +import { AccountType } from '@yikart/common' +import { AccountStatus, PublishType } from '@yikart/mongodb' +import { BilibiliPublishOption } from '../transports/channel/api/bilibili.common' +import { FacebookPostOptions, InstagramPostOptions, ThreadsPostOptions } from '../transports/channel/api/meta.common' +import { WxGzhPublishOption } from '../transports/channel/api/wxGzh.common' +import { YoutubePublishOption } from '../transports/channel/api/youtube.common' + +export interface AccountPortraitReportData { + accountId?: string + userId?: string + type: AccountType + uid: string // 频道平台唯一ID + avatar?: string + nickname?: string + status?: AccountStatus + contentTags?: Record + totalFollowers?: number + totalWorks?: number + totalViews?: number + totalLikes?: number + totalCollects?: number +} + +export interface PlatOptions { + bilibili?: BilibiliPublishOption + youtube?: YoutubePublishOption + wxGzh?: WxGzhPublishOption + facebook?: FacebookPostOptions + threads?: ThreadsPostOptions + instagram?: InstagramPostOptions +} + +export interface NewPublishData { + readonly flowId?: string + readonly accountId: string + readonly type: PublishType + readonly title?: string + readonly desc?: string + readonly videoUrl?: string + readonly coverUrl?: string + readonly imgList?: string[] + topics?: string[] + readonly publishTime?: Date + readonly option?: T +} + +export interface NewPublishRecordData { + userId: string + readonly flowId?: string + type: PublishType + title?: string + desc?: string // 主要内容 + readonly accountId: string + topics: string[] + accountType: AccountType + uid: string + videoUrl?: string + taskId?: string + userTaskId?: string + taskMaterialId?: string + coverUrl?: string + imgUrlList?: string[] + publishTime: Date + readonly imgList?: string[] + errorMsg?: string + workLink?: string // 作品链接 + option: any +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/bilibiliData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/bilibiliData.service.ts new file mode 100644 index 000000000..a2629d68e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/bilibiliData.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common' +import { DataCubeApi } from './dataCube.api' +import { DataCubeBase } from './dataCube.base' + +@Injectable() +export class BilibiliDataService extends DataCubeBase { + constructor(private readonly dataCubeApi: DataCubeApi) { + super() + } + + async getAccountDataCube(accountId: string) { + const res = await this.dataCubeApi.getAccountDataCube(accountId) + return res + } + + async getAccountDataBulk(accountId: string) { + const res = await this.dataCubeApi.getAccountDataBulk(accountId) + return res + } + + async getArcDataCube(accountId: string, dataId: string) { + const res = await this.dataCubeApi.getArcDataCube(accountId, dataId) + return res + } + + async getArcDataBulk(accountId: string, dataId: string) { + const res = await this.dataCubeApi.getArcDataBulk(accountId, dataId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/common.ts new file mode 100644 index 000000000..6fcd96c9b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/common.ts @@ -0,0 +1,46 @@ +export interface ChannelAccountDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number + // 稿件数量 + arcNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelAccountDataBulk extends ChannelAccountDataCube { + // 每天 + list: ChannelAccountDataCube[] +} + +export interface ChannelArcDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelArcDataBulk extends ChannelAccountDataCube { + recordId: string + dataId: string + + // 每天 + list: ChannelArcDataCube[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.api.ts new file mode 100644 index 000000000..874d6c7c2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.api.ts @@ -0,0 +1,72 @@ +import { HttpService } from '@nestjs/axios' +import { Injectable } from '@nestjs/common' +import axios from 'axios' + +import { ChannelAccountDataBulk, ChannelAccountDataCube, ChannelArcDataBulk, ChannelArcDataCube } from './common' + +@Injectable() +export class DataCubeApi { + constructor( + private readonly httpService: HttpService, + ) { } + + /** + * 获取账号数据 + * @param accountId + * @returns + */ + async getAccountDataCube(accountId: string) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/dataCube/getAccountDataCube', + { accountId }, + ) + return res.data + } + + /** + * 获取账号增量数据 + * @param accountId + * @returns + */ + async getAccountDataBulk(accountId: string) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/dataCube/getAccountDataBulk', + { accountId }, + ) + return res.data + } + + /** + * 获取账号下的作品数据 + * @param accountId + * @param dataId + * @returns + */ + async getArcDataCube(accountId: string, dataId: string) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/dataCube/getArcDataCube', + { + accountId, + dataId, + }, + ) + return res.data + } + + /** + * 获取账号下的作品增量数据 + * @param accountId + * @param dataId + * @returns + */ + async getArcDataBulk(accountId: string, dataId: string) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/dataCube/getArcDataBulk', + { + accountId, + dataId, + }, + ) + return res.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.base.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.base.ts new file mode 100644 index 000000000..0fb06f762 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.base.ts @@ -0,0 +1,29 @@ +import { ChannelAccountDataBulk, ChannelAccountDataCube, ChannelArcDataBulk, ChannelArcDataCube } from './common' + +export abstract class DataCubeBase { + // 获取账号的统计数据 + abstract getAccountDataCube( + accountId: string, + pageId?: string, // 可选参数,适用于Facebook等平台 + ): Promise + + // 获取账号的增量数据 + abstract getAccountDataBulk( + accountId: string, + pageId?: string, // 可选参数,适用于Facebook等平台 + ): Promise + + // 获取作品的统计数据 + abstract getArcDataCube( + accountId: string, + dataId: string, + pageId?: string, // 可选参数,适用于Facebook等平台 + ): Promise + + // 获取作品的增量数据 + abstract getArcDataBulk( + accountId: string, + dataId: string, + pageId?: string, // 可选参数,适用于Facebook等平台 + ): Promise +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.controller.ts new file mode 100644 index 000000000..da90f3827 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.controller.ts @@ -0,0 +1,81 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 频道数据 + */ +import { Controller, Get, Param } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AccountType, AppException } from '@yikart/common' +import { AccountService } from '../../account/account.service' +import { BilibiliDataService } from './bilibiliData.service' +import { DataCubeBase } from './dataCube.base' +import { YouTubeDataService } from './youtubeData.service' + +@ApiTags('渠道用户数据') +@Controller('channel/dataCube') +export class DataCubeController { + private readonly dataCubeMap = new Map() + constructor( + readonly bilibiliDataService: BilibiliDataService, + readonly youtubeDataService: YouTubeDataService, + private readonly accountService: AccountService, + ) { + this.dataCubeMap.set(AccountType.BILIBILI, bilibiliDataService) + this.dataCubeMap.set(AccountType.YOUTUBE, youtubeDataService) + } + + private async getDataCube(accountId: string) { + const account = await this.accountService.getAccountById(accountId) + if (!account) + throw new AppException(1, '账户不存在') + const dataCube = this.dataCubeMap.get(account.type) + if (!dataCube) + throw new AppException(1, '暂不支持该账户类型') + return dataCube + } + + @ApiOperation({ summary: '获取账号的统计数据' }) + @Get('accountDataCube/:accountId') + async getAccountDataCube( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + const dataCube = await this.getDataCube(accountId) + return dataCube.getAccountDataCube(accountId) + } + + @ApiOperation({ summary: '获取账号的统计数据' }) + @Get('getAccountDataBulk/:accountId') + async getAccountDataBulk( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + const dataCube = await this.getDataCube(accountId) + return dataCube.getAccountDataBulk(accountId) + } + + @ApiOperation({ summary: '获取账号的统计数据' }) + @Get('getArcDataCube/:accountId/:dataId') + async getArcDataCube( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('dataId') dataId: string, + ) { + const dataCube = await this.getDataCube(accountId) + return dataCube.getArcDataCube(accountId, dataId) + } + + @ApiOperation({ summary: '获取账号的统计数据' }) + @Get('getArcDataBulk/:accountId/:dataId') + async getArcDataBulk( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('dataId') dataId: string, + ) { + const dataCube = await this.getDataCube(accountId) + return dataCube.getArcDataBulk(accountId, dataId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.module.ts new file mode 100644 index 000000000..e24fd71cc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/dataCube.module.ts @@ -0,0 +1,13 @@ +import { HttpModule } from '@nestjs/axios' +import { Module } from '@nestjs/common' +import { BilibiliDataService } from './bilibiliData.service' +import { DataCubeApi } from './dataCube.api' +import { DataCubeController } from './dataCube.controller' +import { YouTubeDataService } from './youtubeData.service' + +@Module({ + imports: [HttpModule], + controllers: [DataCubeController], + providers: [DataCubeApi, BilibiliDataService, YouTubeDataService], +}) +export class DataCubeModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/youtubeData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/youtubeData.service.ts new file mode 100644 index 000000000..316fd8c88 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dataCube/youtubeData.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common' +import { DataCubeApi } from './dataCube.api' +import { DataCubeBase } from './dataCube.base' + +@Injectable() +export class YouTubeDataService extends DataCubeBase { + constructor(private readonly dataCubeApi: DataCubeApi) { + super() + } + + async getAccountDataCube(accountId: string) { + const res = await this.dataCubeApi.getAccountDataCube(accountId) + return res + } + + async getAccountDataBulk(accountId: string) { + const res = await this.dataCubeApi.getAccountDataBulk(accountId) + return res + } + + async getArcDataCube(accountId: string, dataId: string) { + const res = await this.dataCubeApi.getArcDataCube(accountId, dataId) + return res + } + + async getArcDataBulk(accountId: string, dataId: string) { + const res = await this.dataCubeApi.getArcDataBulk(accountId, dataId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/interact.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/interact.dto.ts new file mode 100644 index 000000000..9950bc558 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/interact.dto.ts @@ -0,0 +1,29 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: interact + */ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const AddArcCommentSchema = z.object({ + accountId: z.string({ message: '账号ID' }), + dataId: z.string({ message: '作品ID' }), + content: z.string({ message: '内容' }), +}) +export class AddArcCommentDto extends createZodDto(AddArcCommentSchema) {} + +const ReplyCommentSchema = z.object({ + accountId: z.string({ message: '账号ID' }), + commentId: z.string({ message: '评论ID' }), + content: z.string({ message: '内容' }), +}) +export class ReplyCommentDto extends createZodDto(ReplyCommentSchema) {} + +const DelCommentSchema = z.object({ + accountId: z.string({ message: '账号ID' }), + commentId: z.string({ message: '评论ID' }), +}) +export class DelCommentDto extends createZodDto(DelCommentSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish-response.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish-response.dto.ts new file mode 100644 index 000000000..6932d2f71 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish-response.dto.ts @@ -0,0 +1,113 @@ +import { ApiProperty } from '@nestjs/swagger' +import { AccountType } from '@yikart/common' +import { PublishStatus } from '@yikart/mongodb' +import { IsOptional } from 'class-validator' +import { PublishingChannel } from '../../transports/channel/common' +// import { AccountType } from '@yikart/common' + +export class PublishRecordItemDto { + @ApiProperty({ description: '数据ID' }) + dataId: string + + @ApiProperty({ description: '记录ID' }) + id: string + + @ApiProperty({ example: 'flow001', description: '流程ID' }) + flowId: string + + @ApiProperty({ + example: 'video', + description: '发布类型(如 video, article)', + }) + type: string + + @ApiProperty({ example: '标题示例', description: '发布标题' }) + title: string + + @ApiProperty({ example: '这是一个描述', description: '发布描述' }) + desc: string + + @ApiProperty({ example: 'ACCOUNT-001', description: '账号ID' }) + accountId: string + + @ApiProperty({ enum: AccountType, description: '账号类型' }) + accountType: AccountType + + @ApiProperty({ example: 'USER-001', description: '用户UID' }) + uid: string + + @ApiProperty({ + example: 'https://example.com/video.mp4', + description: '视频地址', + required: false, + }) + videoUrl?: string + + @ApiProperty({ + example: 'https://example.com/cover.jpg', + description: '封面图地址', + required: false, + }) + coverUrl?: string + + @ApiProperty({ + example: [ + 'https://example.com/image1.jpg', + 'https://example.com/image2.jpg', + ], + description: '图片列表', + }) + imgUrlList: string[] + + @ApiProperty({ example: '2025-04-27T18:00:18Z', description: '发布时间' }) + publishTime: Date + + @ApiProperty({ enum: PublishStatus, description: '发布状态' }) + status: PublishStatus + + @ApiProperty({ + example: '错误信息(可为空)', + description: '错误信息', + nullable: true, + }) + errorMsg: string + + @ApiProperty({ enum: PublishingChannel, description: '发布渠道', required: false }) + @IsOptional() + publishingChannel: PublishingChannel + + @ApiProperty({ + example: 'https://example.com/work/123', + description: '作品链接', + required: false, + }) + workLink?: string +} + +export class PostEngagementDto { + @ApiProperty({ example: 0, description: '浏览量' }) + viewCount: number + + @ApiProperty({ example: 3, description: '评论数' }) + commentCount: number + + @ApiProperty({ example: 0, description: '点赞数' }) + likeCount: number + + @ApiProperty({ example: 0, description: '分享数' }) + shareCount: number + + @ApiProperty({ example: 1, description: '点击数' }) + clickCount: number + + @ApiProperty({ example: 8, description: '曝光数' }) + impressionCount: number + + @ApiProperty({ example: 0, description: '收藏数' }) + favoriteCount: number +} + +export class PostHistoryItemDto extends PublishRecordItemDto { + @ApiProperty({ example: '{}', description: '作品互动统计数据' }) + engagement: PostEngagementDto +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish.dto.ts new file mode 100644 index 000000000..30c958d60 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/dto/publish.dto.ts @@ -0,0 +1,175 @@ +import { ApiProperty } from '@nestjs/swagger' +import { AccountType, createZodDto } from '@yikart/common' +import { PublishStatus, PublishType } from '@yikart/mongodb' +import { Expose, Transform } from 'class-transformer' +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDate, + IsEnum, + IsOptional, + IsString, +} from 'class-validator' +import { z } from 'zod' +import { PublishingChannel } from '../../transports/channel/common' + +export const CreatePublishSchema = z.object({ + flowId: z.string({ message: '流水ID' }).optional(), + accountId: z.string({ message: '账户ID' }), + accountType: z.nativeEnum(AccountType, { message: '平台类型' }), + type: z.nativeEnum(PublishType, { message: '类型' }), + title: z.string().optional(), + desc: z.string().optional(), + userTaskId: z.string({ message: '用户任务ID' }).optional(), // 用户任务ID + taskMaterialId: z.string({ message: '任务素材ID' }).optional(), // 任务素材ID + videoUrl: z.string().optional(), + coverUrl: z.string().optional(), + imgUrlList: z.array(z.string()).optional(), + publishTime: z + .union([z.date(), z.string().datetime({ offset: true })]) + .transform(arg => new Date(arg)), + topics: z.array(z.string()), + option: z.any().optional(), +}) +export class CreatePublishDto extends createZodDto(CreatePublishSchema) {} + +export class PubRecordListFilterDto { + @ApiProperty({ + title: '账户ID', + required: false, + description: '账户ID', + }) + @IsString({ message: '账户ID' }) + @IsOptional() + @Expose() + readonly accountId?: string + + @ApiProperty({ + title: '第三方平台账户id', + required: false, + description: '第三方平台账户id', + }) + @IsString({ message: '第三方平台账户id' }) + @IsOptional() + @Expose() + readonly uid?: string + + @ApiProperty({ + title: '账户类型', + required: false, + enum: AccountType, + description: '账户类型', + }) + @IsEnum(AccountType, { message: '账户类型' }) + @IsOptional() + @Expose() + readonly accountType?: AccountType + + @ApiProperty({ + title: '类型', + required: false, + enum: PublishType, + description: '类型', + }) + @IsEnum(PublishType, { message: '类型' }) + @IsOptional() + @Expose() + readonly type?: PublishType + + @ApiProperty({ + title: '状态', + required: false, + enum: PublishStatus, + description: '状态', + }) + @IsEnum(PublishStatus, { message: '状态' }) + @IsOptional() + @Expose() + readonly status?: PublishStatus + + @ApiProperty({ title: '创建时间区间,必须为UTC时间', required: false }) + @IsArray({ message: '创建时间区间必须是一个数组' }) + @ArrayMinSize(2, { message: '创建时间区间必须包含两个日期' }) + @ArrayMaxSize(2, { message: '创建时间区间必须包含两个日期' }) + @IsDate({ each: true, message: '创建时间区间中的每个元素必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => + value ? value.map((v: string) => new Date(v)) : undefined, + ) + readonly time?: [Date, Date] + + @ApiProperty({ + title: '发布渠道', + required: false, + enum: PublishingChannel, + description: '发布渠道,通过我们内部系统发布的(internal)或平台原生端(native)', + }) + @IsEnum(PublishingChannel, { message: '状态' }) + @IsOptional() + @Expose() + publishingChannel: PublishingChannel +} + +export class UpdatePublishRecordTimeDto { + @ApiProperty({ title: '数据ID' }) + @IsString({ message: '数据ID' }) + @Expose() + id: string + + @ApiProperty({ title: '新的发布时间', required: false }) + @IsDate({ message: '新的发布时间必须是有效的日期,日期为UTC时间\'' }) + @Transform(({ value }) => { + if (!value) + return undefined + return new Date(value) + }) + @IsOptional() + @Expose() + publishTime: Date +} + +export const createPublishRecordSchema = z.object({ + flowId: z.string().optional(), + dataId: z.string(), + type: z.nativeEnum(PublishType), + status: z.nativeEnum(PublishStatus, { message: '状态' }), + title: z.string().optional(), + desc: z.string().optional(), + userTaskId: z.string().optional().describe('用户任务ID'), + taskMaterialId: z.string().optional().describe('素材ID'), + accountId: z.string(), + topics: z.array(z.string()), + accountType: z.nativeEnum(AccountType), + uid: z.string(), + videoUrl: z.string().optional(), + coverUrl: z.string().optional(), + imgUrlList: z.array(z.string()).optional(), + publishTime: z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + imgList: z.array(z.string()).optional(), + workLink: z.string().optional(), + errorMsg: z.string().optional(), + option: z.any(), +}) +export class CreatePublishRecordDto extends createZodDto(createPublishRecordSchema) { } + +export const PublishDayInfoListFiltersSchema = z.object({ + time: z.tuple([ + z.string().transform((arg) => { + return new Date(arg) + }), + z.string().transform((arg) => { + return new Date(arg) + }), + ]).optional(), +}) +export class PublishDayInfoListFiltersDto extends createZodDto(PublishDayInfoListFiltersSchema) { } + +export const listPostHistorySchema = z.object({ + uid: z.string(), + accountType: z.nativeEnum(AccountType), +}) +export class ListPostHistoryDto extends createZodDto(listPostHistorySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/dto/engagement.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/dto/engagement.dto.ts new file mode 100644 index 000000000..3cd6e2528 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/dto/engagement.dto.ts @@ -0,0 +1,168 @@ +import { createZodDto } from '@yikart/common' +import { AccountType } from '@yikart/statistics-db' +import { z } from 'zod' + +export const KeysetPaginationSchema = z.object({ + before: z.string().nullish().describe('前一页游标, 对应平台使用keyset分页时, 传递该参数获取前一页数据'), + after: z.string().nullish().describe('后一页游标, 对应平台使用keyset分页时, 传递该参数获取下一页数据'), + limit: z.number().min(1).max(50).nullish().describe('每页数量,默认20, 对应平台使用keyset分页时, 传递该参数设置每页数量'), +}).describe('Keyset分页参数') + +export const OffsetPaginationSchema = z.object({ + pageNo: z.number({ message: 'page number' }).min(1).nullish().default(1).describe('分页页码,默认1, 对应平台使用offset分页时, 传递该参数设置页码'), + pageSize: z.number({ message: 'page size' }).min(1).max(100).nullish().default(20).describe('每页数量,默认20, 对应平台使用offset分页时, 传递该参数设置每页数量'), +}).describe('Offset分页参数') + +export const FetchPostCommentsSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID, account列表中的account字段'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + postId: z.string({ message: 'Post ID is required' }).describe('作品ID'), + pagination: z.union([KeysetPaginationSchema, OffsetPaginationSchema]).nullish().describe('分页参数'), +}).describe('获取作品评论请求参数') + +export const FetchCommentRepliesSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('账号ID, account列表中的account字段'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + commentId: z.string({ message: 'comment ID is required' }).describe('评论ID'), + pagination: z.union([KeysetPaginationSchema, OffsetPaginationSchema]).nullish().describe('分页参数'), +}).describe('获取作品评论请求参数') + +export const FetchPostCommentsResponseSchema = z.object({ + comments: z.array(z.object({ + id: z.string().describe('评论ID'), + message: z.string().describe('评论内容'), + author: z.object({ + username: z.string().describe('评论用户名称'), + avatar: z.string().nullish().describe('评论用户头像'), + }).describe('评论用户信息'), + createdAt: z.string().describe('评论创建时间, ISO 8601格式'), + hasReplies: z.boolean().describe('是否有回复评论'), + })).describe('评论列表'), + total: z.number().nullish().describe('评论总数, 仅对应平台使用offset分页时返回该字段'), + cursor: z.object({ + before: z.string().nullish().describe('前一页游标, 对应平台使用keyset分页时返回该字段, meta(facebook/instagram/threads)平台使用keyset分页'), + after: z.string().nullish().describe('后一页游标, 对应平台使用keyset分页时返回该字段, meta(facebook/instagram/threads)平台使用keyset分页'), + }).nullish().describe('分页游标, 仅对应平台使用keyset分页时返回该字段, meta(facebook/instagram/threads)平台使用keyset分页'), +}).describe('获取作品评论响应数据') + +export const PublishCommentRequestSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('account列表中的account字段'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + postId: z.string({ message: 'Post ID is required' }).describe('作品ID'), + message: z.string({ message: 'Comment message is required' }).min(1).max(500).describe('评论内容, 最大500字符'), +}).describe('发布评论请求参数') + +export const publishCommentReplyRequestSchema = z.object({ + accountId: z.string({ message: 'accountId is required' }).describe('account列表中的account字段'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest'], { message: 'platform is required' }).describe('平台'), + commentId: z.string({ message: 'Comment ID is required' }).describe('评论ID'), + message: z.string({ message: 'Comment message is required' }).min(1).max(500).describe('评论内容, 最大500字符'), +}).describe('发布评论回复请求参数') + +export const PublishCommentResponseSchema = z.object({ + id: z.string().nullish().describe('发布成功的评论ID'), + success: z.boolean().describe('是否发布成功'), + error: z.string().nullish().describe('发布失败时的错误信息'), +}).describe('发布评论响应数据') + +export const FetchPostsRequestSchema = z.object({ + platform: z.enum(AccountType).describe('平台'), + uid: z.string().describe('userId, account表中的uid字段'), + page: z.number().min(1).nullable().default(1).describe('页码, 默认1'), + pageSize: z.number().min(1).max(100).nullable().default(20).describe('每页数量, 默认20, 最大100'), +}) + +export const PostSchema = z.object({ + postId: z.string().describe('帖子ID'), + platform: z.string().describe('平台'), + title: z.string().nullable().describe('标题'), + content: z.string().nullable().describe('内容'), + thumbnail: z.string().nullable().describe('封面/缩略图链接'), + mediaType: z.enum(['video', 'image', 'article']).describe('媒体类型 video | image | article'), + permaLink: z.string().nullable().describe('作品外部链接'), + publishTime: z.number().describe('发布时间,时间戳. 毫秒级'), + viewCount: z.number().describe('浏览数'), + commentCount: z.number().describe('评论数'), + likeCount: z.number().describe('点赞数'), + shareCount: z.number().describe('分享数'), + clickCount: z.number().describe('点击数'), + impressionCount: z.number().describe('曝光数'), + favoriteCount: z.number().describe('收藏数'), +}).describe('帖子数据') + +export const FetchPostsResponseSchema = z.object({ + total: z.number().describe('总数'), + posts: z.array(PostSchema).describe('帖子列表'), + hasMore: z.boolean().describe('是否有更多数据, 当值为true时再请求下一页'), +}) + +export const CommentSchema = z.object({ + id: z.string(), + comment: z.string(), +}) + +export const ReplyToCommentsSchema = z.object({ + accountId: z.string().describe('账号ID, account列表中的Id字段'), + postId: z.string().describe('作品ID'), + prompt: z.string().min(1).max(500).optional().describe('提示语, 最大500字符'), + platform: z.enum(['facebook', 'instagram', 'threads', 'twitter', 'youtube', 'tiktok', 'bilibili', 'douyin', 'KWAI', 'xhs', 'linkedin', 'wxGzh', 'pinterest']).describe('平台'), + model: z.string().describe('AI模型名称, 调用AI模块模型列表接口获取'), + comments: z.array(CommentSchema).optional().describe('评论列表, 传递该参数时, 表示选择评论回复,不传递该参数表示自动回复全部评论'), +}) + +export const AIGenCommentSchema = z.object({ + model: z.string().describe('AI模型名称, 调用AI模块模型列表接口获取'), + prompt: z.string().min(1).max(500).nullable().describe('提示语, 最大500字符'), + comments: z.array(CommentSchema).describe('评论列表'), +}) + +export const AIGenCommentResponseSchema = z.object({ + id: z.string(), + comment: z.string(), + reply: z.string(), +}) + +export const ReplyToCommentsResponseSchema = z.object({ + id: z.string(), +}) + +export const FetchAllPostsRequestSchema = z.object({ + platform: z.enum([ + 'bilibili', + 'douyin', + 'facebook', + 'wxGzh', + 'instagram', + 'KWAI', + 'pinterest', + 'threads', + 'tiktok', + 'twitter', + 'xhs', + 'youtube', + ]).describe('平台'), + userId: z.string().optional().describe('userId, account表中的userId字段'), + uid: z.string().optional().describe('userId, account表中的uid字段'), + range: z.object({ + start: z.string().describe('开始时间,ISO格式'), + end: z.string().describe('结束时间,ISO格式'), + }).optional().describe('数据查询时间范围,默认查询所有'), +}) + +export class FetchPostCommentsRequestDto extends createZodDto(FetchPostCommentsSchema) {} +export class FetchPostCommentsResponseDto extends createZodDto(FetchPostCommentsResponseSchema) {} +export class FetchCommentRepliesDto extends createZodDto(FetchCommentRepliesSchema) {} +export class PublishCommentRequestDto extends createZodDto(PublishCommentRequestSchema) {} +export class PublishCommentReplyRequestDto extends createZodDto(publishCommentReplyRequestSchema) {} +export class PublishCommentResponseDto extends createZodDto(PublishCommentResponseSchema) {} +export class KeysetPagination extends createZodDto(KeysetPaginationSchema) {} +export class OffsetPagination extends createZodDto(OffsetPaginationSchema) {} +export class FetchPostsRequestDto extends createZodDto(FetchPostsRequestSchema) { } +export class ReplyToCommentsDto extends createZodDto(ReplyToCommentsSchema) {} +export class AIGenCommentDto extends createZodDto(AIGenCommentSchema) {} + +export class PostVo extends createZodDto(PostSchema) { } +export class FetchPostsResponseVo extends createZodDto(FetchPostsResponseSchema) { } +export class AIGenCommentResponseVo extends createZodDto(AIGenCommentResponseSchema) {} +export class ReplyToCommentsResponseVo extends createZodDto(ReplyToCommentsResponseSchema) {} +export class FetchAllPostsRequestDto extends createZodDto(FetchAllPostsRequestSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.controller.ts new file mode 100644 index 000000000..fd3bd4347 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.controller.ts @@ -0,0 +1,77 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' + +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AIGenCommentDto, AIGenCommentResponseVo, FetchCommentRepliesDto, FetchPostCommentsRequestDto, FetchPostCommentsResponseDto, FetchPostsRequestDto, FetchPostsResponseVo, PublishCommentReplyRequestDto, PublishCommentRequestDto, ReplyToCommentsDto, ReplyToCommentsResponseVo } from './dto/engagement.dto' +import { EngagementService } from './engagement.service' + +@ApiTags('engagement - 用户互动(评论等)') +@Controller('channel/engagement') +export class EngagementController { + constructor( + private readonly engagementService: EngagementService, + ) { } + + @ApiOperation({ summary: '获取不同平台帖子列表' }) + @Post('posts') + async fetchChannelPosts( + @GetToken() token: TokenInfo, + @Body() data: FetchPostsRequestDto, + ): Promise { + return this.engagementService.fetchChannelPosts(data) + } + + @ApiOperation({ summary: '获取作品一级评论列表' }) + @Post('post/comments') + async fetchPostComments( + @GetToken() token: TokenInfo, + @Body() data: FetchPostCommentsRequestDto, + ): Promise { + return this.engagementService.fetchPostComments(data) + } + + @ApiOperation({ summary: '获取评论回复列表' }) + @Post('comment/replies') + async fetchCommentReplies( + @GetToken() token: TokenInfo, + @Body() data: FetchCommentRepliesDto, + ): Promise { + return this.engagementService.fetchCommentReplies(data) + } + + @ApiOperation({ summary: '在作品下发布评论' }) + @Post('post/comments/publish') + async commentOnPost( + @GetToken() token: TokenInfo, + @Body() data: PublishCommentRequestDto, + ): Promise { + return this.engagementService.commentOnPost(data) + } + + @ApiOperation({ summary: '回复评论' }) + @Post('comment/replies/publish') + async replyToComment( + @GetToken() token: TokenInfo, + @Body() data: PublishCommentReplyRequestDto, + ): Promise { + return this.engagementService.replyToComment(data) + } + + @ApiOperation({ summary: 'AI生成回复' }) + @Post('comment/ai/replies') + async generateRepliesByAI( + @GetToken() token: TokenInfo, + @Body() data: AIGenCommentDto, + ): Promise { + return this.engagementService.generateRepliesByAI(token.id, data) + } + + @ApiOperation({ summary: 'AI自动回复评论' }) + @Post('comment/ai/replies/tasks') + async replyToCommentsByAI( + @GetToken() token: TokenInfo, + @Body() data: ReplyToCommentsDto, + ): Promise { + return this.engagementService.replyToCommentsByAI(token.id, data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.module.ts new file mode 100644 index 000000000..87a875381 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { PostModule } from '../../statistics/post/post.module' +import { PostService } from '../../statistics/post/post.service' +import { EngagementController } from './engagement.controller' +import { EngagementService } from './engagement.service' + +@Module({ + imports: [PostModule], + controllers: [EngagementController], + providers: [EngagementService, PostService], + exports: [EngagementService], +}) + +export class EngagementModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.service.ts new file mode 100644 index 000000000..9a3f89566 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/engagement/engagement.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common' + +import { PostService } from '../../statistics/post/post.service' +import { EngagementNatsApi } from '../../transports/channel/api/engagement/engagement.api' +import { AIGenCommentDto, AIGenCommentResponseVo, FetchCommentRepliesDto, FetchPostCommentsRequestDto, FetchPostCommentsResponseDto, FetchPostsRequestDto, FetchPostsResponseVo, PublishCommentReplyRequestDto, PublishCommentRequestDto, PublishCommentResponseDto, ReplyToCommentsDto, ReplyToCommentsResponseVo } from './dto/engagement.dto' + +@Injectable() +export class EngagementService { + constructor( + private readonly engagementNatsApi: EngagementNatsApi, + private readonly postsService: PostService, + ) { + } + + async fetchChannelPosts(data: FetchPostsRequestDto): Promise { + return await this.postsService.getPostsByPlatform({ + platform: data.platform, + uid: data.uid, + page: data.page || 1, + pageSize: data.pageSize || 20, + }) + } + + async fetchPostComments(data: FetchPostCommentsRequestDto): Promise { + return await this.engagementNatsApi.fetchPostComments(data) + } + + async fetchCommentReplies(data: FetchCommentRepliesDto): Promise { + return await this.engagementNatsApi.fetchCommentReplies(data) + } + + async commentOnPost(data: PublishCommentRequestDto): Promise { + return await this.engagementNatsApi.commentOnPost(data) + } + + async replyToComment(data: PublishCommentReplyRequestDto): Promise { + return await this.engagementNatsApi.replyToComment(data) + } + + async generateRepliesByAI(userId: string, data: AIGenCommentDto): Promise { + return await this.engagementNatsApi.generateRepliesByAI(userId, data) + } + + async replyToCommentsByAI(userId: string, data: ReplyToCommentsDto): Promise { + return await this.engagementNatsApi.replyToCommentsByAI(userId, data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/common.ts new file mode 100644 index 000000000..19e63ab9f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/common.ts @@ -0,0 +1,29 @@ +import { AccountType } from '@yikart/common' + +export interface InteractionRecord { + id: string + userId: string + accountId: string + type: AccountType + worksId: string + worksTitle?: string + commentRemark?: string + worksCover?: string + commentContent: string + isLike: 0 | 1 + isCollect: 0 | 1 + createAt: Date + updatedAt: Date +} + +export interface ReplyCommentRecord { + id: string + userId: string + accountId: string + type: AccountType + commentId: string + commentContent: string + replyContent: string + createAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interact.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interact.dto.ts new file mode 100644 index 000000000..b2be778ac --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interact.dto.ts @@ -0,0 +1,22 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const AddArcCommentSchema = z.object({ + accountId: z.string().min(1, { message: '账号ID不能为空' }), + dataId: z.string().min(1, { message: '作品ID不能为空' }), + content: z.string().describe('内容'), +}) +export class AddArcCommentDto extends createZodDto(AddArcCommentSchema) {} + +export const ReplyCommentSchema = z.object({ + accountId: z.string().min(1, { message: '账号ID不能为空' }), + commentId: z.string().min(1, { message: '评论ID不能为空' }), + content: z.string().describe('内容'), +}) +export class ReplyCommentDto extends createZodDto(ReplyCommentSchema) {} + +export const DelCommentSchema = z.object({ + accountId: z.string().min(1, { message: '账号ID不能为空' }), + commentId: z.string().min(1, { message: '评论ID不能为空' }), +}) +export class DelCommentDto extends createZodDto(DelCommentSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interactionRecord.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interactionRecord.dto.ts new file mode 100644 index 000000000..d9b0b40c8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/interactionRecord.dto.ts @@ -0,0 +1,35 @@ +import { AccountType, createZodDto } from '@yikart/common' + +import { z } from 'zod' + +export const AddInteractionRecordSchema = z.object({ + accountId: z.string().min(1, { message: '账号ID不能为空' }), + type: z.enum(AccountType, { message: '账号类型不能为空' }), + worksId: z.string().min(1, { message: '作品ID不能为空' }), + worksTitle: z.string().optional(), + worksCover: z.string().optional(), + worksContent: z.string().optional(), + commentContent: z.string().optional(), + commentRemark: z.string().optional(), + commentTime: z.string().optional(), + likeTime: z.string().optional(), + collectTime: z.string().optional(), +}) +export class AddInteractionRecordDto extends createZodDto(AddInteractionRecordSchema) {} + +export const InteractionRecordFiltersSchema = z.object({ + accountId: z.string().optional(), + type: z.enum(AccountType).optional(), + worksId: z.string().optional(), + time: z.tuple([z.date(), z.date()]).optional(), +}) +export class InteractionRecordFiltersDto extends createZodDto(InteractionRecordFiltersSchema) {} + +export const InteractionRecordListSchema = z.object({ + filters: InteractionRecordFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class InteractionRecordListDto extends createZodDto(InteractionRecordListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/replyCommentRecord.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/replyCommentRecord.dto.ts new file mode 100644 index 000000000..1be8c5e02 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/dto/replyCommentRecord.dto.ts @@ -0,0 +1,29 @@ +import { AccountType, createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const AddReplyCommentRecordSchema = z.object({ + accountId: z.string().min(1, { message: '账号ID不能为空' }), + type: z.enum(AccountType, { message: '账号类型不能为空' }), + commentId: z.string().min(1, { message: '评论ID不能为空' }), + worksId: z.string().optional(), + commentContent: z.string().min(1, { message: '评论内容不能为空' }), + replyContent: z.string().min(1, { message: '回复内容不能为空' }), +}) +export class AddReplyCommentRecordDto extends createZodDto(AddReplyCommentRecordSchema) {} + +export const ReplyCommentRecordFiltersSchema = z.object({ + accountId: z.string().optional(), + type: z.enum(AccountType).optional(), + commentId: z.string().optional(), + time: z.tuple([z.date(), z.date()]).optional(), +}) +export class ReplyCommentRecordFiltersDto extends createZodDto(ReplyCommentRecordFiltersSchema) {} + +export const ReplyCommentRecordListSchema = z.object({ + filters: ReplyCommentRecordFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class ReplyCommentRecordListDto extends createZodDto(ReplyCommentRecordListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.controller.ts new file mode 100644 index 000000000..313fd9678 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.controller.ts @@ -0,0 +1,64 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 互动 + */ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { InteractNatsApi } from '../../transports/channel/api/interact/interact.natsApi' +import { AddArcCommentDto, DelCommentDto, ReplyCommentDto } from './dto/interact.dto' + +@ApiTags('渠道互动') +@Controller('channel/interact') +export class InteractController { + constructor(private readonly interactApi: InteractNatsApi) {} + + @ApiOperation({ summary: '添加作品评论' }) + @Post('addArcComment') + async addArcComment( + @GetToken() token: TokenInfo, + @Body() data: AddArcCommentDto, + ) { + return this.interactApi.addArcComment( + data.accountId, + data.dataId, + data.content, + ) + } + + @ApiOperation({ summary: '获取作品的评论列表' }) + @Get('getArcCommentList/:pageNo/:pageSize') + async getArcCommentList( + @GetToken() token: TokenInfo, + @Query('recordId') recordId: string, + @Param() query: TableDto, + ) { + return this.interactApi.getArcCommentList(recordId, query) + } + + @ApiOperation({ summary: '回复评论' }) + @Post('replyComment') + async replyComment( + @GetToken() token: TokenInfo, + @Body() data: ReplyCommentDto, + ) { + return this.interactApi.replyComment( + data.accountId, + data.commentId, + data.content, + ) + } + + @ApiOperation({ summary: '删除评论' }) + @Delete('delComment') + async delComment( + @GetToken() token: TokenInfo, + @Body() data: DelCommentDto, + ) { + return this.interactApi.delComment(data.accountId, data.commentId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.module.ts new file mode 100644 index 000000000..f0f32c8af --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interact.module.ts @@ -0,0 +1,14 @@ +import { HttpModule } from '@nestjs/axios' +import { Module } from '@nestjs/common' +import { InteractController } from './interact.controller' +import { InteractionRecordController } from './interactionRecord.controller' +import { InteractionRecordService } from './interactionRecord.service' +import { ReplyCommentRecordController } from './replyCommentRecord.controller' + +@Module({ + imports: [HttpModule], + controllers: [InteractController, InteractionRecordController, ReplyCommentRecordController], + providers: [InteractionRecordService], + exports: [InteractionRecordService], +}) +export class InteractModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.controller.ts new file mode 100644 index 000000000..b895229f9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.controller.ts @@ -0,0 +1,49 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 互动记录 + */ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { InteractionRecordNatsApi } from '../../transports/channel/api/interact/interactionRecord.natsApi' +import { AddInteractionRecordDto, InteractionRecordFiltersDto } from './dto/interactionRecord.dto' + +@ApiTags('渠道互动记录') +@Controller('channel/interactionRecord') +export class InteractionRecordController { + constructor(private readonly interactionRecordNatsApi: InteractionRecordNatsApi) {} + + @ApiOperation({ summary: '添加渠道互动记录' }) + @Post() + async add( + @GetToken() token: TokenInfo, + @Body() data: AddInteractionRecordDto, + ) { + return this.interactionRecordNatsApi.add({ + userId: token.id, + ...data, + }) + } + + @ApiOperation({ summary: '获取渠道互动记录列表' }) + @Get('list/:pageNo/:pageSize') + async getArcCommentList( + @GetToken() token: TokenInfo, + @Query() query: InteractionRecordFiltersDto, + @Param() param: TableDto, + ) { + return this.interactionRecordNatsApi.list(token.id, query, param) + } + + @ApiOperation({ summary: '删除记录' }) + @Delete(':id') + async replyComment( + @Param('id') id: string, + ) { + return this.interactionRecordNatsApi.del(id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.service.ts new file mode 100644 index 000000000..792f09ee5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/interactionRecord.service.ts @@ -0,0 +1,74 @@ +import { HttpService } from '@nestjs/axios' +import { Injectable } from '@nestjs/common' +import { AccountType, TableDto } from '@yikart/common' +import axios from 'axios' +import { InteractionRecord } from './common' + +@Injectable() +export class InteractionRecordService { + constructor( + private readonly httpService: HttpService, + ) { } + + async add(data: { + userId: string + accountId: string + type: AccountType + worksId: string + worksTitle?: string + worksCover?: string + worksContent?: string + commentContent?: string + commentRemark?: string + commentTime?: string + likeTime?: string + collectTime?: string + }) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/interactionRecord/add', + data, + ) + return res.data + } + + /** + * 获取作品评论列表 + * @returns + */ + async list(userId: string, filters: { + accountId?: string + type?: AccountType + worksId?: string + time?: [Date, Date] + }, page: TableDto) { + const res = await axios.post<{ + list: InteractionRecord[] + total: number + }>( + 'http://127.0.0.1:3000/api/channel/interactionRecord/list', + { + filters: { + userId, + ...filters, + }, + page, + }, + ) + return res.data + } + + /** + * 删除 + * @param id + * @returns + */ + async del(id: string) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/channel/interactionRecord/del', + { + id, + }, + ) + return res.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/replyCommentRecord.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/replyCommentRecord.controller.ts new file mode 100644 index 000000000..e53528860 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/interact/replyCommentRecord.controller.ts @@ -0,0 +1,49 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 评论回复 + */ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { ReplyCommentRecordNatsApi } from '../../transports/channel/api/interact/replyCommentRecord.natsApi' +import { AddReplyCommentRecordDto, ReplyCommentRecordFiltersDto } from './dto/replyCommentRecord.dto' + +@ApiTags('评论回复记录') +@Controller('channel/replyCommentRecord') +export class ReplyCommentRecordController { + constructor(private readonly replyCommentRecordNatsApi: ReplyCommentRecordNatsApi) {} + + @ApiOperation({ summary: '添加评论回复记录' }) + @Post() + async add( + @GetToken() token: TokenInfo, + @Body() data: AddReplyCommentRecordDto, + ) { + return this.replyCommentRecordNatsApi.add({ + userId: token.id, + ...data, + }) + } + + @ApiOperation({ summary: '获取评论回复记录列表' }) + @Get('list/:pageNo/:pageSize') + async getArcCommentList( + @GetToken() token: TokenInfo, + @Query() query: ReplyCommentRecordFiltersDto, + @Param() param: TableDto, + ) { + return this.replyCommentRecordNatsApi.list(token.id, query, param) + } + + @ApiOperation({ summary: '删除评论回复记录' }) + @Delete(':id') + async replyComment( + @Param('id') id: string, + ) { + return this.replyCommentRecordNatsApi.del(id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/comment.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.controller.ts new file mode 100644 index 000000000..5d27d76d1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Param, Post, Query, Res, UseGuards } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { Response } from 'express' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatKwaiNatsApi } from '../../transports/channel/api/kwai.natsApi' + +@ApiTags('plat/kwai - 快手平台') +@Controller('plat/kwai') +export class KwaiController { + constructor( + private readonly platKwaiNatsApi: PlatKwaiNatsApi, + ) {} + + @ApiOperation({ summary: '开始授权,创建任务' }) + @Get('auth/url/:type') + async getAuth( + @GetToken() token: TokenInfo, + @Param('type') type: 'h5' | 'pc', + @Query('spaceId') spaceId?: string, + ) { + return await this.platKwaiNatsApi.getAuth({ + userId: token.id, + type, + spaceId: spaceId || '', + }) + } + + @ApiOperation({ summary: '获取账号授权状态回调' }) + @Post('auth/create-account/:taskId') + async getAuthInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + return this.platKwaiNatsApi.getAuthInfo(taskId) + } + + // 授权回调,创建账号 + @Public() + @UseGuards(OrgGuard) + @Get('auth/back/:taskId') + async getAccessToken( + @Param('taskId') taskId: string, + @Query() + query: { + code: string + state: string + }, + @Res() res: Response, + ) { + const result = await this.platKwaiNatsApi.createAccountAndSetAccessToken({ + taskId, + ...query, + }) + return res.render('auth/back', result) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.module.ts new file mode 100644 index 000000000..da87b40e8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/kwai/kwai.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { KwaiController } from './kwai.controller' + +@Module({ + imports: [], + controllers: [KwaiController], + providers: [], + exports: [], +}) +export class KwaiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/dto/meta.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/dto/meta.dto.ts new file mode 100644 index 000000000..71e325a03 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/dto/meta.dto.ts @@ -0,0 +1,31 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +enum SubMetaPlatform { + FACEBOOK = 'facebook', + INSTAGRAM = 'instagram', + THREADS = 'threads', + LINKEDIN = 'linkedin', +} + +const GetAuthUrlSchema = z.object({ + platform: z.enum(SubMetaPlatform, { message: '平台名称不能为空' }), + spaceId: z.string().optional(), +}) +export class GetAuthUrlDto extends createZodDto(GetAuthUrlSchema) {} + +const GetAuthInfoSchema = z.object({ + taskId: z.string({ message: '任务ID不能为空' }), +}) +export class GetAuthInfoDto extends createZodDto(GetAuthInfoSchema) {} + +const FacebookPageSelectionSchema = z.object({ + pageIds: z.array(z.string({ message: '页面ID不能为空' })).describe('页面ID列表必须是字符串数组'), +}) +export class FacebookPageSelectionDto extends createZodDto(FacebookPageSelectionSchema) {} + +const CreateAccountAndSetAccessTokenSchema = z.object({ + code: z.string({ message: '授权码不能为空' }), + state: z.string({ message: '状态码不能为空' }), +}) +export class CreateAccountAndSetAccessTokenDto extends createZodDto(CreateAccountAndSetAccessTokenSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.controller.ts new file mode 100644 index 000000000..b5555441c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.controller.ts @@ -0,0 +1,170 @@ +import { Body, Controller, Get, Param, Post, Query, Render } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { PlatMetaNatsApi } from '../../transports/channel/api/meta.natsApi' +import { + CreateAccountAndSetAccessTokenDto, + FacebookPageSelectionDto, + GetAuthUrlDto, +} from './dto/meta.dto' + +@ApiTags('plat/meta - Meta平台') +@Controller('plat/meta') +export class MetaController { + constructor( + private readonly platMetaNatsApi: PlatMetaNatsApi, + ) {} + + @ApiOperation({ summary: '获取Meta平台 oAuth2.0 用户授权页面URL' }) + @Post('auth/url') + async getAuthUrl(@GetToken() token: TokenInfo, @Body() data: GetAuthUrlDto) { + const res = await this.platMetaNatsApi.getAuthUrl(token.id, data.platform, data.spaceId || '') + return res + } + + @ApiOperation({ summary: '查询用户oAuth2.0任务状态' }) + @Get('auth/info/:taskId') + async getAuthInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + const res = await this.platMetaNatsApi.getAuthInfo(taskId) + return res + } + + @ApiOperation({ summary: '查询Facebook用户Pages列表' }) + @Get('facebook/pages') + async getFacebookPages( + @GetToken() token: TokenInfo, + ) { + const res = await this.platMetaNatsApi.getFacebookPages(token.id) + return res + } + + @ApiOperation({ summary: '选择确认Facebook Pages' }) + @Post('facebook/pages') + async selectFacebookPages( + @GetToken() token: TokenInfo, + @Body() data: FacebookPageSelectionDto, + ) { + const res = await this.platMetaNatsApi.selectFacebookPages(token.id, data.pageIds) + return res + } + + @Public() + @ApiOperation({ summary: 'oAuth认证回调后续操作, 保存AccessToken并创建用户' }) + @Get('auth/back') + @Render('auth/meta') + async createAccountAndSetAccessToken( + @Query() query: CreateAccountAndSetAccessTokenDto, + ) { + return await this.platMetaNatsApi.createAccountAndSetAccessToken( + query.code, + query.state, + ) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: '获取Facebook Page的已发布帖子列表' }) + @Get('facebook/:accountId/published_posts') + async getFacebookPagePublishedPosts( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('pageId') pageId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getFacebookPagePublishedPosts( + accountId, + query, + ) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: '获取Facebook Page的Insights数据' }) + @Get('facebook/:accountId/insights') + async getFacebookPageInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getFacebookPageInsights(accountId, query) + } + + @ApiOperation({ summary: '获取Facebook Page的Insights数据' }) + @Get('facebook/:accountId/:postId/insights') + async getFacebookPostInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('postId') postId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getFacebookPostInsights(accountId, postId, query) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: '获取Instagram Account的Insights数据' }) + @Get('instagram/:accountId') + async getInstagramAccountInfo( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getInstagramAccountInfo(accountId, query) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: '获取Instagram Account的Insights数据' }) + @Get('instagram/:accountId/insights') + async getInstagramAccountInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getInstagramAccountInsights(accountId, query) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: 'Instagram Post的Insights数据' }) + @Get('instagram/:accountId/:postId/insights') + async getInstagramPostInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('postId') postId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getInstagramPostInsights(accountId, postId, query) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: 'threads Account的Insights数据' }) + @Get('threads/:accountId/insights') + async getThreadsAccountInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getThreadsAccountInsights(accountId, query) + } + + // Todo: Only allow internal service access + @ApiOperation({ summary: '获取Facebook Page的Insights数据' }) + @Get('threads/:accountId/:postId/insights') + async getThreadsPostInsights( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('postId') postId: string, + @Query() query: any, + ) { + return await this.platMetaNatsApi.getThreadsPostInsights(accountId, postId, query) + } + + @ApiOperation({ summary: 'threads查找location' }) + @Get('threads/locations') + async searchThreadsLocation( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('keyword') keyword: string, + ) { + return await this.platMetaNatsApi.searchThreadsLocations(accountId, keyword) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.module.ts new file mode 100644 index 000000000..82bc7a499 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/meta/meta.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { MetaController } from './meta.controller' + +@Module({ + imports: [], + controllers: [MetaController], + providers: [], + exports: [], +}) +export class MetaModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/comment.ts new file mode 100644 index 000000000..1660a7f78 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/comment.ts @@ -0,0 +1,68 @@ +export interface PinterestApp { + id: string + secret: string + authBackHost: string + baseUrl: string +} + +export enum Country { + US = 'US', + CN = 'CN', + UK = 'UK', +} + +export enum Currency { + USD = 'USD', + UNK = 'UNK', +} + +export interface CreateAccountBody { + country: Country // 'US'; + currency: Currency // USD; + name: string // 广告账户名称; + owner_user_id?: string // 拥有者用户id +} + +export interface CreateBoardBody { + name: string // board名称; +} + +export interface CreatePinBody { + link: string // 点击链接; + title: string // 标题 + description: string // 描述 + dominant_color: string // RGB表示的颜色 主引脚颜色。十六进制数,例如“#6E7874”。 + alt_text: string + board_id: string // 此 Pin 所属的板块。 + media_source: MediaSource + media_id?: string + url?: string + items?: CreatePinBodyItem[] +} + +interface CreatePinBodyItem { + url: string + title?: string // + description?: string + link?: string +} + +interface MediaSource { + source_type: SourceType +} + +export interface SourceType { + multiple_image_base64: 'multiple_image_base64' // + image_base64: 'image_base64' // + multiple_image_url: 'multiple_image_url' // + image_url: 'image_url' // + video_id: 'video_id' // +} + +export enum SourceTypeEnum { + multiple_image_base64 = 'multiple_image_base64', + image_base64 = 'image_base64', + multiple_image_url = 'multiple_image_url', + image_url = 'image_url', + video_id = 'video_id', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/dto/pinterest.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/dto/pinterest.dto.ts new file mode 100644 index 000000000..925ce2a55 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/dto/pinterest.dto.ts @@ -0,0 +1,50 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { SourceTypeEnum } from '../comment' + +const CreateBoardBodySchema = z.object({ + name: z.string({ message: '名称' }), + accountId: z.string().optional(), +}) +export class CreateBoardBodyDto extends createZodDto(CreateBoardBodySchema) {} + +const ListBodySchema = z.object({ + page: z.string({ message: '页码' }), + size: z.string({ message: '每页大小' }), + accountId: z.string().optional(), +}) +export class ListBodyDto extends createZodDto(ListBodySchema) {} + +const MediaSourceSchema = z.object({ + source_type: z.enum(SourceTypeEnum).optional(), + url: z.string().optional(), +}) +export class MediaSource extends createZodDto(MediaSourceSchema) {} + +const CreatePinBodyItemSchema = z.object({ + url: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + link: z.string().optional(), +}) +export class CreatePinBodyItemDto extends createZodDto(CreatePinBodyItemSchema) {} + +const CreatePinBodySchema = z.object({ + board_id: z.string({ message: '此Pin所属board的板块。' }), + link: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + decs: z.string().optional(), + dominant_color: z.string().optional(), + alt_text: z.string().optional(), + media_source: MediaSourceSchema.optional(), + items: z.array(CreatePinBodyItemSchema).optional(), + accountId: z.string().optional(), +}) +export class CreatePinBodyDto extends createZodDto(CreatePinBodySchema) {} + +const WebhookSchema = z.object({ + code: z.string({ message: 'code' }).optional(), + state: z.string({ message: 'state' }).optional(), +}) +export class WebhookDto extends createZodDto(WebhookSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.controller.ts new file mode 100644 index 000000000..3c3000470 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.controller.ts @@ -0,0 +1,113 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { Response } from 'express' +import * as _ from 'lodash' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { + CreateBoardBodyDto, + CreatePinBodyDto, + ListBodyDto, +} from './dto/pinterest.dto' +import { PinterestService } from './pinterest.service' + +@ApiTags('plat/pinterest - PIN平台') +@Controller('plat/pinterest') +export class PinterestController { + constructor(private readonly pinterestService: PinterestService) {} + + @ApiOperation({ summary: '创建board' }) + @Post('board/') + async createBoard(@Body() body: CreateBoardBodyDto) { + return await this.pinterestService.createBoard(body) + } + + @ApiOperation({ summary: '获取board列表信息' }) + @Get('board/') + async getBoardList( + @Query() query: ListBodyDto, + ) { + return await this.pinterestService.getBoardList(query) + } + + @ApiOperation({ summary: '获取单个board' }) + @Get('board/:id') + async getBoardById(@Param('id') id: string, @Query('accountId') accountId: string) { + return await this.pinterestService.getBoardById(id, accountId) + } + + @ApiOperation({ summary: '删除单个board' }) + @Delete('board/:id') + delBoardById(@Param('id') id: string, @Body('accountId') accountId: string) { + return this.pinterestService.delBoardById(id, accountId) + } + + @ApiOperation({ summary: '创建pin' }) + @Post('pin/') + async createPin(@Body() body: CreatePinBodyDto) { + if (_.has(body, 'desc') && _.isString(body.decs)) + body.description = body.decs + return await this.pinterestService.createPin(body) + } + + @ApiOperation({ summary: '获取pin列表' }) + @Get('pin/') + async getPinList( + @Query() query: ListBodyDto, + ) { + return await this.pinterestService.getPinList(query) + } + + @ApiOperation({ summary: '获取pin' }) + @Get('pin/:id') + async getPinById(@Param('id') id: string, @Query('accountId') accountId: string) { + return await this.pinterestService.getPinById(id, accountId) + } + + @ApiOperation({ summary: '删除单个pin' }) + @Delete('pin/:id') + async delPinById(@Param('id') id: string, @Body('accountId') accountId: string) { + return await this.pinterestService.delPinById(id, accountId) + } + + @ApiOperation({ summary: '获取授权登录页面' }) + @Get('getAuth/') + async getAuth( + @GetToken() token: TokenInfo, + @Query('spaceId') spaceId?: string, + ) { + const userId = token.id + return await this.pinterestService.getAuth(userId, spaceId || '') + } + + @ApiOperation({ summary: '查询授权结果' }) + @Get('checkAuth/') + async checkAuth(@Query('taskId') taskId: string) { + return await this.pinterestService.checkAuth(taskId) + } + + @Public() + @UseGuards(OrgGuard) + @Get('authWebhook') + async authWebhook(@Query() query: any, @Res() res: Response) { + const result = await this.pinterestService.authWebhook(query) + return res.render('auth/back', result) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.module.ts new file mode 100644 index 000000000..b30d0db31 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { PinterestController } from './pinterest.controller' +import { PinterestService } from './pinterest.service' + +@Module({ + imports: [], + controllers: [PinterestController], + providers: [PinterestService], +}) +export class PinterestModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.service.ts new file mode 100644 index 000000000..25ee02c22 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/pinterest/pinterest.service.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common' +import { PlatPinterestNatsApi } from '../../transports/channel/api/pinterest.natsApi' +import { + CreateBoardBodyDto, + CreatePinBodyDto, + ListBodyDto, + WebhookDto, +} from './dto/pinterest.dto' + +@Injectable() +export class PinterestService { + constructor(private readonly platPinterestNatsApi: PlatPinterestNatsApi) {} + + /** + * 创建board + * @param body + * @returns + */ + async createBoard(body: CreateBoardBodyDto) { + return await this.platPinterestNatsApi.createBoard(body) + } + + /** + * 获取board列表信息 + * @returns + */ + async getBoardList(body: ListBodyDto) { + return await this.platPinterestNatsApi.getBoardList(body) + } + + /** + * 获取board信息 + * @param id board id + * @param accountId + * @returns + */ + async getBoardById(id: string, accountId: string) { + return await this.platPinterestNatsApi.getBoardById(id, accountId) + } + + /** + * 删除board信息 + * @param id board id + * @param accountId + * @returns + */ + async delBoardById(id: string, accountId: string) { + return await this.platPinterestNatsApi.delBoardById(id, accountId) + } + + /** + * 创建pin + * @param body + * @returns + */ + async createPin(body: CreatePinBodyDto) { + return await this.platPinterestNatsApi.createPin(body) + } + + /** + * 获取pin信息 + * @param id pin id + * @param accountId + * @returns + */ + async getPinById(id: string, accountId: string) { + return await this.platPinterestNatsApi.getPinById(id, accountId) + } + + /** + * 获取pin列表信息 + * @param body + * @returns + */ + async getPinList(body: ListBodyDto) { + return await this.platPinterestNatsApi.getPinList(body) + } + + /** + * 删除pin + * @param id pin id + * @param accountId + * @returns + */ + async delPinById(id: string, accountId: string) { + return await this.platPinterestNatsApi.delPinById(id, accountId) + } + + /** + * 获取用户授权地址 + * @param userId + * @returns + */ + async getAuth(userId: string, spaceId: string) { + return await this.platPinterestNatsApi.getAuth(userId, spaceId) + } + + /** + * 查询授权结果 + * @param taskId + * @returns + */ + async checkAuth(taskId: string) { + return await this.platPinterestNatsApi.checkAuth(taskId) + } + + /** + * 授权回调 + * @param data + * @returns + */ + async authWebhook(data: WebhookDto) { + return await this.platPinterestNatsApi.authWebhook(data) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.controller.ts new file mode 100644 index 000000000..29f50d7e1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.controller.ts @@ -0,0 +1,136 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 发布 + */ +import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common' +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { plainToInstance } from 'class-transformer' +import { PlatPublishNatsApi } from '../transports/channel/api/publish.natsApi' +import { PostHistoryItemDto, PublishRecordItemDto } from './dto/publish-response.dto' +import { + CreatePublishDto, + CreatePublishRecordDto, + PublishDayInfoListFiltersDto, + PubRecordListFilterDto, + UpdatePublishRecordTimeDto, +} from './dto/publish.dto' +import { PublishService } from './publish.service' + +@ApiTags('plat/publish - 平台发布') +@Controller('plat/publish') +export class PublishController { + constructor( + private readonly publishService: PublishService, + private readonly platPublishNatsApi: PlatPublishNatsApi, + ) {} + + @ApiOperation({ summary: '创建发布' }) + @Post('create') + async create(@GetToken() token: TokenInfo, @Body() data: CreatePublishDto) { + data = plainToInstance(CreatePublishDto, data) + return this.publishService.create(data) + } + + @ApiOperation({ summary: '立即执行发布任务(测试用)' }) + @Get('run/:id') + async run(@GetToken() token: TokenInfo, @Param('id') id: string) { + return this.publishService.run(id) + } + + @ApiOperation({ summary: '创建发布记录' }) + @Post('createRecord') + async createRecord(@GetToken() token: TokenInfo, @Body() data: CreatePublishRecordDto) { + data = plainToInstance(CreatePublishRecordDto, data) + return this.publishService.createRecord({ + userId: token.id, + ...data, + }) + } + + @ApiOperation({ summary: '获取发布记录' }) + @Post('getList') + @ApiOkResponse({ + type: PublishRecordItemDto, + isArray: true, + description: '返回发布记录列表', + }) + async getList( + @GetToken() token: TokenInfo, + @Body() data: PubRecordListFilterDto, + ) { + return this.publishService.getList(data, token.id) + } + + @ApiOperation({ summary: '获取平台作品记录' }) + @Post('posts') + @ApiOkResponse({ + type: PostHistoryItemDto, + isArray: true, + description: '返回发布记录列表', + }) + async getPosts( + @GetToken() token: TokenInfo, + @Body() data: PubRecordListFilterDto, + ) { + return this.publishService.getPostHistory(data, token.id) + } + + @ApiOperation({ summary: '修改发布任务时间' }) + @Post('updateTaskTime') + async updatePublishRecordTime( + @GetToken() token: TokenInfo, + @Body() data: UpdatePublishRecordTimeDto, + ) { + return this.platPublishNatsApi.updatePublishRecordTime({ + publishTime: data.publishTime, + userId: token.id, + id: data.id, + }) + } + + @ApiOperation({ + summary: '删除发布任务,注意,只能删除未发布的任务,不能删除已经发布的记录', + }) + @Delete('delete/:id') + async delete(@GetToken() token: TokenInfo, @Param('id') id: string) { + return this.platPublishNatsApi.deletePublishRecord({ + userId: token.id, + id, + }) + } + + @ApiOperation({ + summary: '立即发布任务(在n天之后的任务想要立即发布)', + }) + @Post('nowPubTask/:id') + async nowPubTask(@GetToken() token: TokenInfo, @Param('id') id: string) { + return this.platPublishNatsApi.nowPubTask(id) + } + + @ApiOperation({ summary: '获取发布信息数据' }) + @Get('publishInfo/data') + async publishInfoData(@GetToken() token: TokenInfo) { + return this.publishService.publishInfoData(token.id) + } + + @ApiOperation({ summary: '获取每天发布信息数据列表' }) + @Get('publishDayInfo/list/:pageNo/:pageSize') + async publishDataInfoList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: PublishDayInfoListFiltersDto, + ) { + return this.publishService.publishDataInfoList(token.id, query, param) + } + + @ApiOperation({ summary: '获取发布记录详情' }) + @Get('records/:flowId') + async getPublishRecordDetail(@GetToken() token: TokenInfo, @Param('flowId') flowId: string) { + return this.publishService.getPublishRecordDetail(flowId, token.id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.service.ts new file mode 100644 index 000000000..e240f60db --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/publish.service.ts @@ -0,0 +1,209 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType, AppException, TableDto } from '@yikart/common' +import { PublishRecord, PublishStatus } from '@yikart/mongodb' +import { PostData } from '@yikart/statistics-db' +import { PublishRecordService } from '../publishRecord/publishRecord.service' +import { PostService } from '../statistics/post/post.service' +import { EngagementNatsApi } from '../transports/channel/api/engagement/engagement.api' +import { PlatPublishNatsApi } from '../transports/channel/api/publish.natsApi' +import { PublishTaskNatsApi } from '../transports/channel/api/publishTask.natsApi' +import { PublishingChannel } from '../transports/channel/common' +import { UserTaskNatsApi } from '../transports/task/api/user-task.natsApi' +import { NewPublishData, NewPublishRecordData, PlatOptions } from './common' +import { PostHistoryItemDto } from './dto/publish-response.dto' +import { PublishDayInfoListFiltersDto, PubRecordListFilterDto } from './dto/publish.dto' + +@Injectable() +export class PublishService { + private readonly logger = new Logger(PublishService.name) + constructor( + private readonly platPublishNatsApi: PlatPublishNatsApi, + private readonly publishTaskNatsApi: PublishTaskNatsApi, + private readonly userTaskNatsApi: UserTaskNatsApi, + private readonly engagementNatsApi: EngagementNatsApi, + private readonly publishRecordService: PublishRecordService, + private readonly postService: PostService, + ) { } + + async create(newData: NewPublishData) { + const res = await this.platPublishNatsApi.create(newData) + return res + } + + async createRecord(newData: NewPublishRecordData) { + // 如果有用户ID任务,则传入用户任务ID和任务ID + if (newData.userTaskId) { + const userTask = await this.userTaskNatsApi.getUserTaskInfo(newData.userTaskId) + if (userTask) { + newData.taskId = userTask.taskId + } + } + const res = await this.platPublishNatsApi.createRecord(newData) + return res + } + + async run(id: string) { + const res = await this.platPublishNatsApi.run(id) + return res + } + + async getList(data: PubRecordListFilterDto, userId: string) { + const list1 = await this.publishRecordService.getPublishRecordList({ + ...data, + userId, + }) + const list2 = await this.publishTaskNatsApi.getPublishTaskList(userId, data) + return [...list1, ...list2] + } + + private mergePostHistory(publishRecords: PublishRecord[], publishTasks: any[], postsHistory: PostData[]) { + const result = new Map() + for (const post of postsHistory) { + result.set(post.postId, { + id: post.id, + dataId: post.postId, + flowId: '', + type: post.mediaType, + title: post.title || '', + desc: post.content || '', + accountId: '', + accountType: post.platform as AccountType, + uid: '', + videoUrl: post.mediaType === 'video' ? post.permaLink || '' : undefined, + coverUrl: post.thumbnail || undefined, + imgUrlList: post.mediaType === 'image' ? [post.thumbnail || ''] : [], + publishTime: new Date(post.publishTime), + status: PublishStatus.PUBLISHING, + errorMsg: '', + publishingChannel: PublishingChannel.NATIVE, + workLink: post.permaLink || '', + engagement: { + viewCount: post.viewCount, + commentCount: post.commentCount, + likeCount: post.likeCount, + shareCount: post.shareCount, + clickCount: post.clickCount, + impressionCount: post.impressionCount, + favoriteCount: post.favoriteCount, + }, + }) + } + const defaultEngagement = { + viewCount: 0, + commentCount: 0, + likeCount: 0, + shareCount: 0, + clickCount: 0, + impressionCount: 0, + favoriteCount: 0, + } + + const publishRecordCache = new Map() + for (const record of publishRecords) { + if (record.flowId) { + publishRecordCache.set(record.flowId, record) + } + if (record.dataId && result.has(record.dataId)) { + const post = result.get(record.dataId)! + post.id = record.id + post.title = record.title || '' + post.desc = record.desc || '' + post.flowId = record.flowId || '' + post.accountId = record.accountId + post.accountType = record.accountType + post.uid = record.uid + post.errorMsg = record.errorMsg || '' + post.publishingChannel = PublishingChannel.INTERNAL + if (record.workLink) { + post.workLink = record.workLink + } + } + else { + let status = record.status + if (status === PublishStatus.PUBLISHING && record.publishTime > new Date()) { + status = PublishStatus.PUBLISHING + } + result.set(record.dataId || record.id, { + id: record.id, + flowId: record.flowId || '', + title: record.title || '', + desc: record.desc || '', + dataId: record.dataId, + type: record.type, + accountId: record.accountId, + accountType: record.accountType, + uid: record.uid, + videoUrl: record.videoUrl || '', + coverUrl: record.coverUrl || '', + imgUrlList: record.imgUrlList || [], + publishTime: record.publishTime, + errorMsg: record.errorMsg || '', + status, + engagement: defaultEngagement, + publishingChannel: PublishingChannel.INTERNAL, + workLink: record.workLink || '', + }) + } + } + for (const task of publishTasks) { + if (task.flowId && !publishRecordCache.has(task.flowId)) { + result.set(task.dataId || task.id, { + id: task.id, + ...task, + engagement: defaultEngagement, + publishingChannel: PublishingChannel.INTERNAL, + }) + } + } + return Array.from(result.values()).sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime()) + } + + async getPostHistory(data: PubRecordListFilterDto, userId: string) { + const publishRecords = await this.publishRecordService.getPublishRecordList({ + ...data, + userId, + }) + const publishTasks = await this.publishTaskNatsApi.getPublishTaskList(userId, data) + const range = { start: '', end: '' } + if (data.time) { + range.start = data.time[0].toISOString() + range.end = data.time[1].toISOString() + } + const postsHistory = await this.postService.getUserAllPostsByPlatform({ + ...data, + range, + userId, + platform: data.accountType as any, + }) + const posts = this.mergePostHistory(publishRecords, publishTasks, postsHistory.posts) + if (data.publishingChannel) { + return posts.filter(post => post.publishingChannel === data.publishingChannel) + } + return posts + } + + async publishInfoData(userId: string) { + const res = await this.publishRecordService.getPublishInfoData(userId) + return res + } + + async publishDataInfoList(userId: string, data: PublishDayInfoListFiltersDto, page: TableDto) { + return await this.publishRecordService.getPublishDayInfoList({ userId, time: data.time }, page) + } + + async getPublishRecordDetail(flowId: string, userId: string) { + try { + const record = await this.publishRecordService.getPublishRecordDetail({ flowId, userId }) + return record + } + catch (error: any) { + this.logger.error(`Failed to get publish record detail for flowId ${flowId} and userId ${userId}: ${error.message}`, error.stack) + } + + const task = await this.platPublishNatsApi.getPublishTaskDetail(flowId, userId) + if (!task) { + throw new AppException(400, `publish record with flowId ${flowId} not found.`) + } + return task + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/common.ts new file mode 100644 index 000000000..a77a9c52f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/common.ts @@ -0,0 +1,14 @@ +export enum SkKeyStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +export interface SkKey { + id: string + key: string + desc: string + status: SkKeyStatus + userId: string + createAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/dto/skKey.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/dto/skKey.dto.ts new file mode 100644 index 000000000..166719c72 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/dto/skKey.dto.ts @@ -0,0 +1,24 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const CreateSkKeySchema = z.object({ + desc: z.string({ message: '备注' }).optional(), +}) +export class CreateSkKeyDto extends createZodDto(CreateSkKeySchema) { } + +const SkKeyUpInfoSchema = z.object({ + key: z.string({ message: 'key' }), + desc: z.string({ message: '备注' }), +}) +export class SkKeyUpInfoDto extends createZodDto(SkKeyUpInfoSchema) { } + +const SkKeyAddRefAccountSchema = z.object({ + key: z.string({ message: 'key' }), + accountId: z.string({ message: '账号ID' }), +}) +export class SkKeyAddRefAccountDto extends createZodDto(SkKeyAddRefAccountSchema) { } + +const GetRefAccountListSchema = z.object({ + key: z.string({ message: 'key' }), +}) +export class GetRefAccountListDto extends createZodDto(GetRefAccountListSchema) { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.controller.ts new file mode 100644 index 000000000..4ed52ca48 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.controller.ts @@ -0,0 +1,115 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: 频道MCP的SkKey + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { ChannelSkKeyNatsApi } from '../../transports/channel/api/skKeyNatsApi.natsApi' +import { + CreateSkKeyDto, + GetRefAccountListDto, + SkKeyAddRefAccountDto, + SkKeyUpInfoDto, +} from './dto/skKey.dto' + +@ApiTags('频道MCP的SkKey') +@Controller('channel/skKey') +export class SkKeyController { + constructor(private readonly platSkKeyatsApi: ChannelSkKeyNatsApi) {} + + @ApiOperation({ + summary: '创建skKey', + description: '创建skKey', + }) + @Post() + create(@GetToken() token: TokenInfo, @Body() body: CreateSkKeyDto) { + return this.platSkKeyatsApi.create(token.id, body.desc) + } + + @ApiOperation({ + summary: '删除关联', + description: '删除关联', + }) + @Delete('ref') + delRefAccount( + @GetToken() token: TokenInfo, + @Body() body: any, + ) { + return this.platSkKeyatsApi.delRefAccount(body.key, body.accountId) + } + + @ApiOperation({ + summary: '删除skKey', + description: '删除skKey', + }) + @Delete(':key') + del(@GetToken() token: TokenInfo, @Param('key') key: string) { + return this.platSkKeyatsApi.del(key) + } + + @ApiOperation({ + summary: '更新skKey', + description: '更新skKey', + }) + @Put() + upInfo(@GetToken() token: TokenInfo, @Body() body: SkKeyUpInfoDto) { + return this.platSkKeyatsApi.upInfo(body.key, body.desc) + } + + @ApiOperation({ + summary: '获取skKey', + description: '获取skKey', + }) + @Get('info/:key') + getInfo(@GetToken() token: TokenInfo, @Param('key') key: string) { + return this.platSkKeyatsApi.getInfo(key) + } + + @ApiOperation({ + summary: '获取skKey列表', + description: '获取skKey列表', + }) + @Get('list/:pageNo/:pageSize') + list(@GetToken() token: TokenInfo, @Param() page: TableDto) { + return this.platSkKeyatsApi.list(page, { userId: token.id }) + } + + @ApiOperation({ + summary: '创建关联', + description: '创建关联', + }) + @Post('ref') + addRefAccount( + @GetToken() token: TokenInfo, + @Body() body: SkKeyAddRefAccountDto, + ) { + return this.platSkKeyatsApi.addRefAccount(body.key, body.accountId) + } + + @ApiOperation({ + summary: '获取关联列表', + description: '获取关联列表', + }) + @Get('ref/list/:pageNo/:pageSize') + getRefAccountList( + @GetToken() token: TokenInfo, + @Param() page: TableDto, + @Query() query: GetRefAccountListDto, + ) { + return this.platSkKeyatsApi.getRefAccountList(query.key, page) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.module.ts new file mode 100644 index 000000000..abb4e3007 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/skKey/skKey.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common' +import { SkKeyController } from './skKey.controller' + +@Module({ + imports: [], + controllers: [SkKeyController], +}) +export class SkKeyModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/dto/tiktok.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/dto/tiktok.dto.ts new file mode 100644 index 000000000..7fe93deb9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/dto/tiktok.dto.ts @@ -0,0 +1,60 @@ +/* + * @Author: AI Assistant + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: AI Assistant + * @Description: TikTok Platform DTO + */ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const GetAuthUrlSchema = z.object({ + scopes: z.array(z.string()).optional(), + spaceId: z.string().optional(), +}) +export class GetAuthUrlDto extends createZodDto(GetAuthUrlSchema) {} + +const GetAuthInfoSchema = z.object({ + taskId: z.string({ message: '任务ID不能为空' }), +}) +export class GetAuthInfoDto extends createZodDto(GetAuthInfoSchema) {} + +const CreateAccountAndSetAccessTokenSchema = z.object({ + code: z.string({ message: '授权码不能为空' }), + state: z.string({ message: '状态码不能为空' }), +}) +export class CreateAccountAndSetAccessTokenDto extends createZodDto(CreateAccountAndSetAccessTokenSchema) {} + +const AccountIdSchema = z.object({ + accountId: z.string({ message: '账号ID不能为空' }), +}) +export class AccountIdDto extends createZodDto(AccountIdSchema) {} + +const RefreshTokenSchema = AccountIdSchema.extend({ + refreshToken: z.string({ message: '刷新令牌不能为空' }), +}) +export class RefreshTokenDto extends createZodDto(RefreshTokenSchema) {} + +const VideoPublishSchema = AccountIdSchema.extend({ + postInfo: z.object({}).describe('发布信息必须是对象'), + sourceInfo: z.object({}).describe('源信息必须是对象'), +}) +export class VideoPublishDto extends createZodDto(VideoPublishSchema) {} + +const PhotoPublishSchema = AccountIdSchema.extend({ + postMode: z.string({ message: '发布模式不能为空' }), + postInfo: z.object({}).describe('发布信息必须是对象'), + sourceInfo: z.object({}).describe('源信息必须是对象'), +}) +export class PhotoPublishDto extends createZodDto(PhotoPublishSchema) {} + +const GetPublishStatusSchema = AccountIdSchema.extend({ + publishId: z.string({ message: '发布ID不能为空' }), +}) +export class GetPublishStatusDto extends createZodDto(GetPublishStatusSchema) {} + +const UploadVideoFileSchema = z.object({ + uploadUrl: z.string({ message: '上传URL不能为空' }), + contentType: z.string({ message: '内容类型不能为空' }), +}) +export class UploadVideoFileDto extends createZodDto(UploadVideoFileSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.controller.ts new file mode 100644 index 000000000..c76909384 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.controller.ts @@ -0,0 +1,177 @@ +/* + * @Author: AI Assistant + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: AI Assistant + * @Description: TikTok Platform Controller + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Res, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { Response } from 'express' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatTiktokNatsApi } from '../../transports/channel/api/tiktok.natsApi' +import { + CreateAccountAndSetAccessTokenDto, + GetAuthUrlDto, + PhotoPublishDto, + RefreshTokenDto, + UploadVideoFileDto, + VideoPublishDto, +} from './dto/tiktok.dto' +import { TiktokService } from './tiktok.service' + +@ApiTags('plat/tiktok - TikTok平台') +@Controller('plat/tiktok') +export class TiktokController { + constructor( + private readonly tiktokService: TiktokService, + private readonly platTiktokNatsApi: PlatTiktokNatsApi, + ) {} + + @ApiOperation({ summary: '获取页面的认证URL' }) + @Post('auth/url') + async getAuthUrl(@GetToken() token: TokenInfo, @Body() data: GetAuthUrlDto) { + const res = await this.platTiktokNatsApi.getAuthUrl(token.id, data.scopes, data.spaceId || '') + return res + } + + @ApiOperation({ summary: '查询认证信息' }) + @Get('auth/info/:taskId') + async getAuthInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + return this.platTiktokNatsApi.getAuthInfo(taskId) + } + + @Public() + @UseGuards(OrgGuard) + @ApiOperation({ summary: '创建账号并设置授权Token' }) + @Get('auth/back') + async createAccountAndSetAccessToken( + @Query() data: CreateAccountAndSetAccessTokenDto, + @Res() res: Response, + ) { + const result = await this.platTiktokNatsApi.createAccountAndSetAccessToken( + data.code, + data.state, + ) + return res.render('auth/back', result) + } + + @ApiOperation({ summary: '刷新访问令牌' }) + @Post('auth/refresh-token') + async refreshAccessToken( + @GetToken() token: TokenInfo, + @Body() data: RefreshTokenDto, + ) { + return this.platTiktokNatsApi.refreshAccessToken( + data.accountId, + data.refreshToken, + ) + } + + @ApiOperation({ summary: '撤销访问令牌' }) + @Post('auth/revoke-token/:accountId') + async revokeAccessToken( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.platTiktokNatsApi.revokeAccessToken(accountId) + } + + @ApiOperation({ summary: '获取创作者信息' }) + @Get('creator/info/:accountId') + async getCreatorInfo( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.platTiktokNatsApi.getCreatorInfo(accountId) + } + + @ApiOperation({ summary: '检查账号状态' }) + @Get('account/status/:accountId') + async checkAccountStatus( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return await this.tiktokService.checkAccountStatus(accountId) + } + + @ApiOperation({ summary: '初始化视频发布' }) + @Post('publish/video/init') + async initVideoPublish( + @GetToken() token: TokenInfo, + @Body() data: VideoPublishDto, + ) { + return this.platTiktokNatsApi.initVideoPublish( + data.accountId, + data.postInfo, + data.sourceInfo, + ) + } + + @ApiOperation({ summary: '初始化照片发布' }) + @Post('publish/photo/init') + async initPhotoPublish( + @GetToken() token: TokenInfo, + @Body() data: PhotoPublishDto, + ) { + return this.platTiktokNatsApi.initPhotoPublish( + data.accountId, + data.postMode, + data.postInfo, + data.sourceInfo, + ) + } + + @ApiOperation({ summary: '查询发布状态' }) + @Get('publish/status/:accountId/:publishId') + async getPublishStatus( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + @Param('publishId') publishId: string, + ) { + return this.platTiktokNatsApi.getPublishStatus(accountId, publishId) + } + + @ApiOperation({ + summary: '上传视频文件', + description: '上传视频文件到指定的上传URL', + }) + @UseInterceptors(FileInterceptor('file')) + @Post('upload/video') + async uploadVideoFile( + @GetToken() token: TokenInfo, + @UploadedFile() file: any, + @Body() body: UploadVideoFileDto, + ) { + return this.tiktokService.uploadVideoFile( + body.uploadUrl, + file, + body.contentType, + ) + } + + @ApiOperation({ summary: 'TikTok Webhook事件接收' }) + @Public() + @Post('webhook') + async handleWebhookEvent( + @Body() event: any, + ) { + return this.tiktokService.handleWebhookEvent(event) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.module.ts new file mode 100644 index 000000000..455d6ba06 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.module.ts @@ -0,0 +1,18 @@ +/* + * @Author: AI Assistant + * @Date: 2025-01-08 00:00:00 + * @LastEditTime: 2025-01-08 00:00:00 + * @LastEditors: AI Assistant + * @Description: TikTok Platform Module + */ +import { Module } from '@nestjs/common' +import { TiktokController } from './tiktok.controller' +import { TiktokService } from './tiktok.service' + +@Module({ + imports: [], + controllers: [TiktokController], + providers: [TiktokService], + exports: [TiktokService], +}) +export class TiktokModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.service.ts new file mode 100644 index 000000000..d9fb04259 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/tiktok/tiktok.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException } from '@yikart/common' +import { PlatTiktokNatsApi } from '../../transports/channel/api/tiktok.natsApi' + +@Injectable() +export class TiktokService { + logger = new Logger(TiktokService.name) + constructor(private readonly platTiktokNatsApi: PlatTiktokNatsApi) {} + + /** + * 上传视频文件 + * @param uploadUrl 上传URL + * @param file 文件对象 + * @param contentType 内容类型 + * @returns + */ + async uploadVideoFile( + uploadUrl: string, + file: any, + contentType: string, + ) { + // file转换为base64 + const { buffer } = file + const base64 = buffer.toString('base64') + + const { code, data, message } + = await this.platTiktokNatsApi.uploadVideoFile( + uploadUrl, + base64, + contentType, + ) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 检查账号状态 + * @param accountId 账号ID + * @returns + */ + async checkAccountStatus(accountId: string) { + try { + const result = await this.platTiktokNatsApi.getCreatorInfo(accountId) + return !!result.data + } + catch (error) { + this.logger.error(error) + return false + } + } + + /** + * 处理TikTok Webhook事件 + * @param event Webhook事件数据 + * @returns + */ + async handleWebhookEvent(event: any) { + try { + // 处理事件逻辑 + return await this.platTiktokNatsApi.handleWebhookEvent(event) + } + catch (error) { + this.logger.error(error) + throw new AppException(500, '处理Webhook事件失败') + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/dto/twitter.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/dto/twitter.dto.ts new file mode 100644 index 000000000..21164208b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/dto/twitter.dto.ts @@ -0,0 +1,19 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const GetAuthUrlSchema = z.object({ + scopes: z.array(z.string()).optional(), + spaceId: z.string().optional(), +}) +export class GetAuthUrlDto extends createZodDto(GetAuthUrlSchema) {} + +const GetAuthInfoSchema = z.object({ + taskId: z.string({ message: '任务ID不能为空' }), +}) +export class GetAuthInfoDto extends createZodDto(GetAuthInfoSchema) {} + +const CreateAccountAndSetAccessTokenSchema = z.object({ + code: z.string({ message: '授权码不能为空' }), + state: z.string({ message: '状态码不能为空' }), +}) +export class CreateAccountAndSetAccessTokenDto extends createZodDto(CreateAccountAndSetAccessTokenSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.controller.ts new file mode 100644 index 000000000..f7e7185db --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, Post, Query, Res, UseGuards } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { Response } from 'express' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatTwitterNatsApi } from '../../transports/channel/api/twitter.natsApi' +import { + CreateAccountAndSetAccessTokenDto, + GetAuthUrlDto, +} from './dto/twitter.dto' + +@ApiTags('plat/twitter - Twitter平台') +@Controller('plat/twitter') +export class TwitterController { + constructor( + private readonly platTwitterNatsApi: PlatTwitterNatsApi, + ) {} + + @ApiOperation({ summary: '获取Twitter oAuth2.0 用户授权页面URL' }) + @Post('auth/url') + async getAuthUrl(@GetToken() token: TokenInfo, @Body() data: GetAuthUrlDto) { + const res = await this.platTwitterNatsApi.getAuthUrl(token.id, data.scopes, data.spaceId || '') + return res + } + + @ApiOperation({ summary: '查询用户oAuth2.0任务状态' }) + @Get('auth/info/:taskId') + async getAuthInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + const res = await this.platTwitterNatsApi.getAuthInfo(taskId) + return res + } + + @Public() + @UseGuards(OrgGuard) + @ApiOperation({ summary: 'oAuth认证回调后续操作, 保存AccessToken并创建用户' }) + @Get('auth/back') + async createAccountAndSetAccessToken( + @Query() data: CreateAccountAndSetAccessTokenDto, + @Res() res: Response, + ) { + const result = await this.platTwitterNatsApi.createAccountAndSetAccessToken( + data.code, + data.state, + ) + return res.render('auth/back', result) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.module.ts new file mode 100644 index 000000000..f3c52edae --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/twitter/twitter.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { TwitterController } from './twitter.controller' + +@Module({ + imports: [], + controllers: [TwitterController], + providers: [], + exports: [], +}) +export class TwitterModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/common.ts new file mode 100644 index 000000000..23bed6918 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/common.ts @@ -0,0 +1,62 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface BClient { + clientName: string + clientId: string + clientSecret: string + authBackUrl: string +} + +export interface AddArchiveData { + title: string // 标题 + cover?: string // 封面url + tid: number // 分区ID,由获取分区信息接口得到 + no_reprint?: 0 | 1 // 是否允许转载 0-允许,1-不允许。默认0 + desc?: string // 描述 + tag: string // 标签, 多个标签用英文逗号分隔,总长度小于200 + copyright: 1 | 2 // 1-原创,2-转载(转载时source必填) + source?: string // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number // 参加的话题ID,默认情况下不填写,需要填写和运营联系 +} + +// 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 +export enum WxPublishStatus { + Success = 0, + Publishing = 1, + OriginalFail = 2, + RegularFail = 3, + PlatformAuditFail = 4, + SuccessAfterUserDeleteAllArticle = 5, + SuccessAfterSystemBanAllArticle = 6, +} +export interface CallbackMsgData { + appId: string + + // 公众号的ghid + ToUserName: string + // 公众号群发助手的openid,为mphelper + FromUserName: string + // 创建时间的时间戳 + CreateTime: number + // 消息类型,此处为event + MsgType: string + // 事件信息,此处为PUBLISHJOBFINISH + Event: string + // 发布任务id + publish_id: string + // 发布状态,0:成功, 1:发布中,2:原创失败, 3: 常规失败, 4:平台审核不通过, 5:成功后用户删除所有文章, 6: 成功后系统封禁所有文章 + publish_status: WxPublishStatus + // 当发布状态为0时(即成功)时,返回图文的 article_id,可用于“客服消息”场景 + article_id: string + // 当发布状态为0时(即成功)时,返回文章数量 + count?: number + // 当发布状态为0时(即成功)时,返回文章对应的编号 + idx?: number + // 当发布状态为0时(即成功)时,返回图文的永久链接 + article_url?: string + // 当发布状态为2或4时,返回不通过的文章编号,第一篇为 1;其他发布状态则为空 + fail_idx?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/dto/wxGzh.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/dto/wxGzh.dto.ts new file mode 100644 index 000000000..1492c1ca9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/dto/wxGzh.dto.ts @@ -0,0 +1,76 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:16:37 + * @LastEditors: nevin + * @Description: + */ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' +import { ArchiveStatus } from '../../../transports/channel/api/bilibili.common' +import { VideoUTypes } from '../common' + +const AccountIdSchema = z.object({ + accountId: z.string({ message: '账号ID' }), +}) +export class AccountIdDto extends createZodDto(AccountIdSchema) {} + +export class AccessBackDto { + code: string + state: string +} + +const VideoInitSchema = AccountIdSchema.extend({ + name: z.string({ message: '文件名称' }), + utype: z.nativeEnum(VideoUTypes).optional(), +}) +export class VideoInitDto extends createZodDto(VideoInitSchema) {} + +const UploadLitVideoSchema = z.object({ + uploadToken: z.string({ message: '上传token' }), +}) +export class UploadLitVideoDto extends createZodDto(UploadLitVideoSchema) {} + +const UploadVideoPartSchema = UploadLitVideoSchema.extend({ + partNumber: z.number({ message: '分片索引' }), +}) +export class UploadVideoPartDto extends createZodDto(UploadVideoPartSchema) {} + +export class VideoCompleteDto extends createZodDto(UploadLitVideoSchema) {} + +const ArchiveAddByUtokenBodySchema = z.object({ + accountId: z.string({ message: '账号ID' }), + uploadToken: z.string({ message: '上传token' }), + title: z.string().regex(/^(?!\s*$).+/, { message: '标题不能为空或仅包含空白字符' }), + cover: z.string().optional(), + tid: z.number({ message: '分区ID,由获取分区信息接口得到' }), + noReprint: z.union([z.literal(0), z.literal(1)]).optional(), + desc: z.string().optional(), + tag: z.array(z.string()).min(1, { message: '最少添加一个标签' }).describe('标签必须是字符串数组'), + copyright: z.union([z.literal(1), z.literal(2)], { message: '1-原创,2-转载(转载时source必填)' }), + source: z.string().optional(), +}) +export class ArchiveAddByUtokenBodyDto extends createZodDto(ArchiveAddByUtokenBodySchema) {} + +const GetArchiveListSchema = AccountIdSchema.extend({ + status: z.nativeEnum(ArchiveStatus).optional(), +}) +export class GetArchiveListDto extends createZodDto(GetArchiveListSchema) {} + +const GetArcStatSchema = AccountIdSchema.extend({ + resourceId: z.string({ message: '稿件ID' }), +}) +export class GetArcStatDto extends createZodDto(GetArcStatSchema) {} + +const GetUserCumulateDataSchema = AccountIdSchema.extend({ + beginDate: z.string({ message: '开始日期' }), + endDate: z.string({ message: '结束日期' }), +}) +export class GetUserCumulateData extends createZodDto(GetUserCumulateDataSchema) {} + +const AuthBackQuerySchema = z.object({ + stat: z.string({ message: ' 透传数据(任务ID)' }), + auth_code: z.string({ message: '授权码' }), + expires_in: z.number({ message: '过期时间' }), +}) +export class AuthBackQueryDto extends createZodDto(AuthBackQuerySchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.controller.ts new file mode 100644 index 000000000..ff5f9265e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.controller.ts @@ -0,0 +1,60 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: wxgzh + */ +import { Controller, Get, Param, Query } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { GetUserCumulateData } from './dto/wxGzh.dto' +import { WxGzhService } from './wxGzh.service' + +@ApiTags('plat/wxGzh - 微信公众号') +@Controller('plat/wxGzh') +export class WxGzhController { + constructor(private readonly wxGzhService: WxGzhService) {} + + @ApiOperation({ summary: '发起微信授权登陆' }) + @Get('auth/url/:type') + async createAuthTask( + @GetToken() token: TokenInfo, + @Param('type') type: 'h5' | 'pc', + @Query('spaceId') spaceId?: string, + ) { + const res = await this.wxGzhService.createAuthTask(token.id, type, spaceId || '') + + return { + id: res.taskId, + url: res.url, + } + } + + @ApiOperation({ summary: '获取账号授权状态回调' }) + @Get('auth/create-account/:taskId') + async getAuthTaskInfo( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + return this.wxGzhService.getAuthTaskInfo(taskId) + } + + @ApiOperation({ summary: '获取累计用户数据' }) + @Get('account/userCumulate') + async getUserCumulate( + @GetToken() token: TokenInfo, + @Query() query: GetUserCumulateData, + ) { + return this.wxGzhService.getUserCumulate(query.accountId, query.beginDate, query.endDate) + } + + @ApiOperation({ summary: '获取图文阅读概况数据' }) + @Get('account/userRead') + async getUserRead( + @GetToken() token: TokenInfo, + @Query() query: GetUserCumulateData, + ) { + return this.wxGzhService.getUserRead(query.accountId, query.beginDate, query.endDate) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.module.ts new file mode 100644 index 000000000..3a732dc15 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { WxGzhController } from './wxGzh.controller' +import { WxGzhService } from './wxGzh.service' +import { WxPlatController } from './wxPlat.controller' + +@Module({ + imports: [], + controllers: [WxGzhController, WxPlatController], + providers: [WxGzhService], + exports: [WxGzhService], +}) +export class WxGzhModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.service.ts new file mode 100644 index 000000000..86a01739a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxGzh.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common' +import { PlatWxGzhNatsApi } from '../../transports/channel/api/wxGzh.natsApi' + +@Injectable() +export class WxGzhService { + constructor(private readonly platWxGzhNatsApi: PlatWxGzhNatsApi) {} + + // 创建授权任务 + async createAuthTask(userId: string, type: 'pc' | 'h5', spaceId: string) { + const prefix = '' + const res = await this.platWxGzhNatsApi.createAuthTask( + userId, + type, + prefix, + spaceId, + ) + return res + } + + // 获取授权任务信息 + async getAuthTaskInfo(taskId: string) { + const res = await this.platWxGzhNatsApi.getAuthTaskInfo(taskId) + return res + } + + // 获取累计用户数据 + async getUserCumulate(accountId: string, beginDate: string, endDate: string) { + const res = await this.platWxGzhNatsApi.getUserCumulate(accountId, beginDate, endDate) + return res + } + + // 获取图文阅读概况数据 + async getUserRead(accountId: string, beginDate: string, endDate: string) { + const res = await this.platWxGzhNatsApi.getUserRead(accountId, beginDate, endDate) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxPlat.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxPlat.controller.ts new file mode 100644 index 000000000..aea98bcec --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/wxGzh/wxPlat.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Post, UseGuards } from '@nestjs/common' +import { Public } from '@yikart/aitoearn-auth' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatWxGzhNatsApi } from '../../transports/channel/api/wxGzh.natsApi' +import { CallbackMsgData } from './common' +import { AuthBackQueryDto } from './dto/wxGzh.dto' + +@Controller('plat/wx') +export class WxPlatController { + constructor( + private readonly platWxGzhNatsApi: PlatWxGzhNatsApi, + ) {} + + /** + * 接收授权回调 + * @param query + * @returns + */ + @Public() + @UseGuards(OrgGuard) + @Post('auth/back') + async authBackGet( + @Body() body: AuthBackQueryDto, + ) { + await this.platWxGzhNatsApi.createAccountAndSetAccessToken({ + taskId: body.stat, + auth_code: body.auth_code, + expires_in: body.expires_in, + }) + return 'success' + } + + /** + * 接收消息回调 + * @param body + * @returns + */ + @Public() + @UseGuards(OrgGuard) + @Post('callback/msg') + async callbackMsg( + @Body() body: CallbackMsgData, + ) { + if (body.MsgType === 'event' && body.Event === 'PUBLISHJOBFINISH') { + this.platWxGzhNatsApi.updatePublishRecord( + body, + ) + } + + return 'success' + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/comments.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/comments.ts new file mode 100644 index 000000000..2bc79952f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/comments.ts @@ -0,0 +1,9 @@ +export const CommonParams = { + REGION: ['AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT', 'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI', 'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY', 'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN', 'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM', 'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK', 'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL', 'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM', 'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR', 'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS', 'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK', 'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW', 'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP', 'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM', 'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW', 'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM', 'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF', 'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW', 'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI', 'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW'], // 国家区域代码 +} as const + +export type RegionCode = typeof CommonParams.REGION[number] + +export function getRegionCodes(): readonly RegionCode[] { + return CommonParams.REGION +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/dto/youtube.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/dto/youtube.dto.ts new file mode 100644 index 000000000..386c3906f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/dto/youtube.dto.ts @@ -0,0 +1,309 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +/** + * 账号ID DTO + */ +const AccountIdSchema = z.object({ + accountId: z.string({ message: '账号ID必须是字符串' }), +}) + +export class AccountIdDto extends createZodDto(AccountIdSchema) {} + +/** + * 获取视频类别列表 DTO + * 注意:id和regionCode只能选择一个 + */ +const GetVideoCategoriesSchema = AccountIdSchema.extend({ + id: z.string({ + message: '视频类别ID必须是字符串, 注意:id和regionCode必须有且只能选择一个', + }).optional(), + regionCode: z.string({ + message: '区域代码必须是字符串, 注意:id和regionCode只能选择一个', + }).optional(), +}) + +export class GetVideoCategoriesDto extends createZodDto(GetVideoCategoriesSchema) {} + +/** + * 获取视频列表 DTO + */ +const GetVideosListSchema = AccountIdSchema.extend({ + chart: z.string().optional(), + id: z.string().optional(), + myRating: z.boolean().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), +}) +export class GetVideosListDto extends createZodDto(GetVideosListSchema) {} + +/** + * 上传小视频(小于20MB) DTO + */ +const UploadSmallVideoSchema = AccountIdSchema.extend({ + title: z.string({ message: '标题必须是字符串' }), + description: z.string({ message: '描述必须是字符串' }), + privacyStatus: z.string({ message: '隐私状态必须是字符串' }), + keywords: z.string().optional(), + categoryId: z.string().optional(), + publishAt: z.string().optional(), +}) +export class UploadSmallVideoDto extends createZodDto(UploadSmallVideoSchema) {} + +/** + * 初始化视频上传 DTO + */ +const InitVideoUploadSchema = AccountIdSchema.extend({ + title: z.string({ message: '标题必须是字符串' }), + description: z.string({ message: '描述必须是字符串' }), + keywords: z.string().optional(), + categoryId: z.string().optional(), + privacyStatus: z.string({ message: '隐私状态必须是字符串' }), + publishAt: z.string().optional(), + contentLength: z.number({ message: '视频文件总大小,字节数' }), +}) +export class InitVideoUploadDto extends createZodDto(InitVideoUploadSchema) {} + +/** + * 上传视频分片 DTO + */ +const UploadVideoPartSchema = AccountIdSchema.extend({ + uploadToken: z.string({ message: '上传token' }), + partNumber: z.number({ message: '分片索引' }), +}) +export class UploadVideoPartDto extends createZodDto(UploadVideoPartSchema) {} + +/** + * 完成视频上传 DTO + */ +const UploadVideoCompleteSchema = AccountIdSchema.extend({ + uploadToken: z.string({ message: '上传令牌必须是字符串' }), + totalSize: z.number({ message: '视频文件总大小,字节数' }), +}) +export class UploadVideoCompleteDto extends createZodDto(UploadVideoCompleteSchema) {} + +/** + * 获取子评论列表 DTO + */ +const GetCommentsListSchema = AccountIdSchema.extend({ + id: z.string().optional(), + parentId: z.string().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), +}) +export class GetCommentsListDto extends createZodDto(GetCommentsListSchema) {} + +// 创建顶级评论 +const InsertCommentThreadsSchema = AccountIdSchema.extend({ + channelId: z.string({ message: '频道ID必须是字符串' }), + videoId: z.string({ message: '视频ID必须是字符串' }), + textOriginal: z.string({ message: '评论内容必须是字符串' }), +}) +export class InsertCommentThreadsDto extends createZodDto(InsertCommentThreadsSchema) {} + +// 获取评论会话列表 +const GetCommentThreadsListSchema = AccountIdSchema.extend({ + id: z.string().optional(), + allThreadsRelatedToChannelId: z.string().optional(), + videoId: z.string().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), + order: z.enum(['time', 'relevance']).optional(), + searchTerms: z.string().optional(), +}) +export class GetCommentThreadsListDto extends createZodDto(GetCommentThreadsListSchema) {} + +// 定义 topLevelComment.snippet 的子类 +const SecondLevelCommentSnippetSchema = z.object({ + textOriginal: z.string().optional(), + parentId: z.string().optional(), +}) +export class SecondLevelCommentSnippetDto extends createZodDto(SecondLevelCommentSnippetSchema) {} + +// 创建二级评论 +const InsertCommentSchema = z.object({ + textOriginal: z.string().optional(), + parentId: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class InsertCommentDto extends createZodDto(InsertCommentSchema) {} + +// 更新评论 +const UpdateCommentSchema = z.object({ + id: z.string({ message: '评论ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), + textOriginal: z.string({ message: '评论内容必须是字符串' }), +}) +export class UpdateCommentDto extends createZodDto(UpdateCommentSchema) {} + +// 设置评论审核状态 +const SetCommentThreadsModerationStatusSchema = z.object({ + id: z.string({ message: '评论ID' }), + moderationStatus: z.enum(['heldForReview', 'published', 'rejected']), + banAuthor: z.boolean().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class SetCommentThreadsModerationStatusDto extends createZodDto(SetCommentThreadsModerationStatusSchema) {} + +// 删除评论 +const DeleteCommentSchema = z.object({ + id: z.string({ message: '评论ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class DeleteCommentDto extends createZodDto(DeleteCommentSchema) {} + +// 对视频的点赞、踩 +const VideoRateSchema = z.object({ + id: z.string({ message: '视频ID' }), + rating: z.enum(['like', 'dislike', 'none']), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class VideoRateDto extends createZodDto(VideoRateSchema) {} + +// 获取视频的点赞、踩 +const GetVideoRateSchema = z.object({ + id: z.string({ message: '视频ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class GetVideoRateDto extends createZodDto(GetVideoRateSchema) {} + +// 删除视频 +const DeleteVideoSchema = z.object({ + id: z.string({ message: '视频ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class DeleteVideoDto extends createZodDto(DeleteVideoSchema) {} + +// 更新视频 +const UpdateVideoSchema = z.object({ + id: z.string({ message: '视频ID' }), + title: z.string({ message: '标题' }), + categoryId: z.string({ message: '类别ID' }), + defaultLanguage: z.string().optional(), + description: z.string().optional(), + privacyStatus: z.string().optional(), + tags: z.string().optional(), + publishAt: z.string().optional(), + recordingDate: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class UpdateVideoDto extends createZodDto(UpdateVideoSchema) {} + +// 创建播放列表 +const InsertPlayListSchema = z.object({ + title: z.string({ message: '标题' }), + description: z.string().optional(), + privacyStatus: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class InsertPlayListDto extends createZodDto(InsertPlayListSchema) {} + +// 获取播放列表 +const GetPlayListSchema = z.object({ + channelId: z.string().optional(), + id: z.string().optional(), + mine: z.boolean().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class GetPlayListDto extends createZodDto(GetPlayListSchema) {} + +// 更新播放列表 +const UpdatePlayListSchema = z.object({ + id: z.string({ message: '播放列表ID' }), + title: z.string({ message: '标题' }), + description: z.string().optional(), + privacyStatus: z.string().optional(), + podcastStatus: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class UpdatePlayListDto extends createZodDto(UpdatePlayListSchema) {} + +// 删除播放列表 +const DeletePlayListSchema = z.object({ + id: z.string({ message: '播放列表ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class DeletePlayListDto extends createZodDto(DeletePlayListSchema) {} + +// 获取播放列表项 +const GetPlayItemsSchema = z.object({ + id: z.string().optional(), + playlistId: z.string().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), + videoId: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class GetPlayItemsDto extends createZodDto(GetPlayItemsSchema) {} + +// 插入播放列表项 +const InsertPlayItemsSchema = z.object({ + playlistId: z.string({ message: '播放列表ID' }), + resourceId: z.string({ message: '资源ID' }), + position: z.number().optional(), + note: z.string().optional(), + startAt: z.string().optional(), + endAt: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class InsertPlayItemsDto extends createZodDto(InsertPlayItemsSchema) {} + +// 更新播放列表项 +const UpdatePlayItemsSchema = z.object({ + id: z.string({ message: '播放列表项ID' }), + playlistId: z.string({ message: '播放列表ID' }), + resourceId: z.string({ message: '资源ID' }), + position: z.number().optional(), + note: z.string().optional(), + startAt: z.string().optional(), + endAt: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class UpdatePlayItemsDto extends createZodDto(UpdatePlayItemsSchema) {} + +// 删除播放列表项 +const DeletePlayItemsSchema = z.object({ + id: z.string({ message: '播放列表项ID' }), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class DeletePlayItemsDto extends createZodDto(DeletePlayItemsSchema) {} + +// 获取频道列表 +const GetChannelsListSchema = z.object({ + forHandle: z.string().optional(), + forUsername: z.string().optional(), + id: z.string().optional(), + mine: z.boolean().optional(), + maxResults: z.number().optional(), + pageToken: z.string().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class GetChannelsListDto extends createZodDto(GetChannelsListSchema) {} + +// 获取频道板块列表 +const GetChannelsSectionsListSchema = z.object({ + channelId: z.string().optional(), + id: z.string().optional(), + mine: z.boolean().optional(), + accountId: z.string({ message: '账号ID必须是字符串' }), +}) +export class GetChannelsSectionsListDto extends createZodDto(GetChannelsSectionsListSchema) {} + +/** + * YouTube搜索接口DTO + */ +const SearchSchema = z.object({ + accountId: z.string({ message: '账号ID必须是字符串' }), + forMine: z.boolean().optional(), + maxResults: z.number().optional(), + order: z.enum(['relevance', 'date', 'rating', 'title', 'videoCount', 'viewCount']).optional(), + pageToken: z.string().optional(), + publishedBefore: z.string().optional(), + publishedAfter: z.string().optional(), + q: z.string().optional(), + type: z.enum(['video', 'channel', 'playlist']).optional(), + videoCategoryId: z.string().optional(), +}) +export class SearchDto extends createZodDto(SearchSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.controller.ts new file mode 100644 index 000000000..e438dd238 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.controller.ts @@ -0,0 +1,518 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Get, + Logger, + Param, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { Response } from 'express' +import { OrgGuard } from '../../common/interceptor/transform.interceptor' +import { PlatYoutubeNatsApi } from '../../transports/channel/api/youtube.natsApi' +import { + DeleteCommentDto, + DeletePlayItemsDto, + DeletePlayListDto, + DeleteVideoDto, + GetChannelsListDto, + GetChannelsSectionsListDto, + GetCommentsListDto, + GetCommentThreadsListDto, + GetPlayItemsDto, + GetPlayListDto, + GetVideoCategoriesDto, + GetVideoRateDto, + GetVideosListDto, + // InitVideoUploadDto, + InsertCommentDto, + InsertCommentThreadsDto, + InsertPlayItemsDto, + InsertPlayListDto, + SearchDto, + SetCommentThreadsModerationStatusDto, + UpdateCommentDto, + UpdatePlayItemsDto, + UpdatePlayListDto, + UpdateVideoDto, + VideoRateDto, +} from './dto/youtube.dto' +import { YoutubeService } from './youtube.service' + +@ApiTags('plat/youtube - YouTube平台') +@Controller('plat/youtube') +export class YoutubeController { + private readonly logger = new Logger(YoutubeController.name) + + constructor( + private readonly youtubeService: YoutubeService, + private readonly platYoutubeNatsApi: PlatYoutubeNatsApi, + ) {} + + @ApiOperation({ summary: '获取页面的认证URL' }) + @Get('auth/url') + async getAuthUrl( + @GetToken() token: TokenInfo, + @Query('spaceId') spaceId?: string, + ) { + this.logger.log(`token: ${token}`) + if (!token.mail) { + throw new Error('缺少邮箱') + } + const res = await this.platYoutubeNatsApi.getAuthUrl( + token.id, + token.mail, + undefined, + spaceId || '', + ) + return res + } + + @ApiOperation({ summary: '获取账号授权状态回调' }) + @Post('auth/create-account/:taskId') + /** + * 获取账号授权异步任务的状态 + */ + async getAuthenTaskStatus( + @GetToken() token: TokenInfo, + @Param('taskId') taskId: string, + ) { + return await this.youtubeService.getAuthTaskStatus(taskId) + } + + // 获取AccessToken,并记录到用户,给平台回调用 + @Public() + @UseGuards(OrgGuard) + @Get('auth/callback') + async getAccessToken( + @Query() + query: { + code: string + state: string + }, + @Res() res: Response, + ) { + const stateData = JSON.parse(decodeURIComponent(query.state)) + const taskId = stateData.originalState // Use originalState as taskId + const result = await this.platYoutubeNatsApi.setAccessToken( + { + taskId, + ...query, + }, + ) + return res.render('auth/back', result) + } + + @ApiOperation({ summary: '检查账号是否已经授权' }) + @Get('auth/status/:accountId') + async checkAccountAuthStatus( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return await this.youtubeService.checkAccountAuthStatus(accountId) + } + + @ApiOperation({ summary: '刷新令牌token' }) + @Post('auth/refresh-token/:accountId') + async refreshToken( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return this.youtubeService.refreshToken(accountId) + } + + @ApiOperation({ summary: '获取视频类别列表' }) + @Get('video/categories') + async getVideoCategories( + @GetToken() token: TokenInfo, + @Query() query: GetVideoCategoriesDto, + ) { + return await this.youtubeService.getVideoCategories( + query.accountId, + query.id, + query.regionCode, + ) + } + + @ApiOperation({ summary: '获取视频列表' }) + @Get('video/list') + async getVideosList( + @GetToken() token: TokenInfo, + @Query() query: GetVideosListDto, + ) { + return await this.youtubeService.getVideosList( + query.accountId, + query.chart, + query.id, + query.myRating, + query.maxResults, + query.pageToken, + ) + } + + // 创建评论会话 + @ApiOperation({ summary: '创建评论会话' }) + @Post('comment/threads/insert') + async insertCommentThreads( + @GetToken() token: TokenInfo, + @Body() body: InsertCommentThreadsDto, + ) { + return await this.youtubeService.insertCommentThreads( + body.accountId, + body.channelId, + body.videoId, + body.textOriginal, + ) + } + + // 获取评论会话列表 + @ApiOperation({ summary: '获取评论会话列表' }) + @Get('comment/threads/list') + async getCommentThreadsList( + @GetToken() token: TokenInfo, + @Query() query: GetCommentThreadsListDto, + ) { + return await this.youtubeService.getCommentThreadsList( + query.accountId, + query.allThreadsRelatedToChannelId, + query.id, + query.videoId, + query.maxResults, + query.pageToken, + query.order, + query.searchTerms, + ) + } + + // 设置评论会话的审核状态 + @ApiOperation({ summary: '设置评论会话的审核状态' }) + @Post('comment/threads/moderation/set') + async setCommentThreadsModerationStatus( + @GetToken() token: TokenInfo, + @Body() body: SetCommentThreadsModerationStatusDto, + ) { + return await this.youtubeService.setCommentThreadsModerationStatus( + body.accountId, + body.id, + body.moderationStatus, + body?.banAuthor, + ) + } + + // 创建二级评论 + @ApiOperation({ summary: '创建二级评论' }) + @Post('comment/insert') + async insertComment( + @GetToken() token: TokenInfo, + @Body() body: InsertCommentDto, + ) { + return await this.youtubeService.insertComment( + body.accountId, + body.parentId, + body.textOriginal, + ) + } + + // 获取子评论列表 + @ApiOperation({ summary: '获取子评论列表' }) + @Get('comment/list') + async getCommentsList( + @GetToken() token: TokenInfo, + @Query() query: GetCommentsListDto, + ) { + return await this.youtubeService.getCommentsList( + query.accountId, + query.id, + query.parentId, + query.maxResults, + query.pageToken, + ) + } + + // 更新评论 + @ApiOperation({ summary: '更新评论' }) + @Post('comment/update') + async updateComment( + @GetToken() token: TokenInfo, + @Body() body: UpdateCommentDto, + ) { + return await this.youtubeService.updateComment( + body.accountId, + body.id, + body.textOriginal, + ) + } + + // 删除评论 + @ApiOperation({ summary: '删除评论' }) + @Post('comment/delete') + async deleteComment( + @GetToken() token: TokenInfo, + @Body() body: DeleteCommentDto, + ) { + return await this.youtubeService.deleteComment(body.accountId, body.id) + } + + // 设置视频的点赞、踩 + @ApiOperation({ summary: '设置视频的点赞、踩' }) + @Post('video/rating/set') + async setVideoRate(@GetToken() token: TokenInfo, @Body() body: VideoRateDto) { + return await this.youtubeService.setVideoRate( + body.accountId, + body.id, + body.rating, + ) + } + + // 获取视频的点赞、踩 + @ApiOperation({ summary: '获取视频的点赞、踩' }) + @Get('video/rating') + async getVideoRate( + @GetToken() token: TokenInfo, + @Query() query: GetVideoRateDto, + ) { + return await this.youtubeService.getVideoRate(query.accountId, query.id) + } + + // 删除视频 + @ApiOperation({ summary: '删除视频' }) + @Post('video/delete') + async deleteVideo( + @GetToken() token: TokenInfo, + @Body() body: DeleteVideoDto, + ) { + return await this.youtubeService.deleteVideo(body.accountId, body.id) + } + + // 更新视频 + @ApiOperation({ summary: '更新视频' }) + @Post('video/update') + async updateVideo( + @GetToken() token: TokenInfo, + @Body() body: UpdateVideoDto, + ) { + return await this.youtubeService.updateVideo( + body.accountId, + body.id, + body.title, + body.categoryId, + body?.defaultLanguage, + body?.description, + body?.privacyStatus, + body?.tags, + body?.publishAt, + body?.recordingDate, + ) + } + + // 创建播放列表 + @ApiOperation({ summary: '创建播放列表' }) + @Post('playlist/create') + async createPlaylist( + @GetToken() token: TokenInfo, + @Body() body: InsertPlayListDto, + ) { + return await this.youtubeService.createPlaylist( + body.accountId, + body.title, + body.description, + body.privacyStatus, + ) + } + + // 更新播放列表 + @ApiOperation({ summary: '更新播放列表' }) + @Post('playlist/update') + async updatePlaylist( + @GetToken() token: TokenInfo, + @Body() body: UpdatePlayListDto, + ) { + return await this.youtubeService.updatePlaylist( + body.accountId, + body.id, + body.title, + body.description, + ) + } + + // 删除播放列表 + @ApiOperation({ summary: '删除播放列表' }) + @Post('playlist/delete') + async deletePlaylist( + @GetToken() token: TokenInfo, + @Body() body: DeletePlayListDto, + ) { + return await this.youtubeService.deletePlaylist(body.accountId, body.id) + } + + // 获取播放列表 + @ApiOperation({ summary: '获取播放列表' }) + @Post('playlist/list') + async getPlayList( + @GetToken() token: TokenInfo, + @Body() body: GetPlayListDto, + ) { + return await this.youtubeService.getPlayList( + body.accountId, + body?.channelId, + body?.id, + body?.mine, + body?.maxResults, + body?.pageToken, + ) + } + + // 插入播放列表项 + @ApiOperation({ summary: '插入播放列表项' }) + @Post('playlist/items/insert') + async insertPlayListItems( + @GetToken() token: TokenInfo, + @Body() body: InsertPlayItemsDto, + ) { + return await this.youtubeService.insertPlayListItems( + body.accountId, + body.playlistId, + body.resourceId, + body?.position, + body?.note, + body?.startAt, + body?.endAt, + ) + } + + // 更新播放列表项 + @ApiOperation({ summary: '更新播放列表项' }) + @Post('playlist/items/update') + async updatePlayListItems( + @GetToken() token: TokenInfo, + @Body() body: UpdatePlayItemsDto, + ) { + return await this.youtubeService.updatePlayListItems( + body.accountId, + body.id, + body.playlistId, + body?.resourceId, + body?.position, + body?.note, + body?.startAt, + body?.endAt, + ) + } + + // 删除播放列表项 + @ApiOperation({ summary: '删除播放列表项' }) + @Post('playlist/items/delete') + async deletePlayListItems( + @GetToken() token: TokenInfo, + @Body() body: DeletePlayItemsDto, + ) { + return await this.youtubeService.deletePlayListItems( + body.accountId, + body.id, + ) + } + + // 获取播放列表项 + @ApiOperation({ summary: '获取播放列表项' }) + @Post('playlist/items/list') + async getPlayListItems( + @GetToken() token: TokenInfo, + @Body() body: GetPlayItemsDto, + ) { + return await this.youtubeService.getPlayListItems( + body.accountId, + body?.id, + body?.playlistId, + body?.maxResults, + body?.pageToken, + body?.videoId, + ) + } + + // 获取频道列表 + @ApiOperation({ summary: '获取频道列表' }) + @Get('channel/list') + async getChannelsList( + @GetToken() token: TokenInfo, + @Query() query: GetChannelsListDto, + ) { + return await this.youtubeService.getChannelsList( + query.accountId, + query?.forHandle, + query?.forUsername, + query?.id, + query?.mine, + query?.maxResults, + query?.pageToken, + ) + } + + // 更新账号频道ID + @ApiOperation({ summary: '更新账号频道ID' }) + @Get('channel/update/channelId/:accountId') + async updateChannelId( + @GetToken() token: TokenInfo, + @Param('accountId') accountId: string, + ) { + return await this.youtubeService.updateChannelId( + accountId, + ) + } + + // 获取频道板块列表 + @ApiOperation({ summary: '获取频道板块列表' }) + @Post('channel/sections/list') + async getChannelsSectionsList( + @GetToken() token: TokenInfo, + @Body() body: GetChannelsSectionsListDto, + ) { + return await this.youtubeService.getChannelsSectionsList( + body.accountId, + body?.channelId, + body?.id, + body?.mine, + ) + } + + // 获取通用数据 + @ApiOperation({ summary: '获取通用数据' }) + @Get('common/params') + async getCommonParams() { + return await this.youtubeService.getCommonParams() + } + + /** + * YouTube搜索接口 + * 支持多种搜索条件和排序方式 + */ + @ApiOperation({ summary: '搜索' }) + @Post('search') + async search( + @GetToken() token: TokenInfo, + @Body() body: SearchDto, + ) { + return await this.youtubeService.search( + body.accountId, + body?.forMine, + body?.maxResults, + body?.order, + body?.pageToken, + body?.publishedBefore, + body?.publishedAfter, + body?.q, + body?.type, + body?.videoCategoryId, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.module.ts new file mode 100644 index 000000000..debd1719b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { YoutubeController } from './youtube.controller' +import { YoutubeService } from './youtube.service' + +@Module({ + imports: [], + controllers: [YoutubeController], + providers: [YoutubeService], + exports: [YoutubeService], +}) +export class YoutubeModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.service.ts new file mode 100644 index 000000000..cd0b0af94 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/channel/youtube/youtube.service.ts @@ -0,0 +1,741 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException } from '@yikart/common' +import { RedisService } from '@yikart/redis' +import { AccountService } from '../../account/account.service' +import { PlatYoutubeNatsApi } from '../../transports/channel/api/youtube.natsApi' +import { getRegionCodes } from './comments' + +export interface AuthTaskInfo { + state: string + userId: string + mail: string + status: 0 | 1 + accountId?: string + avatar?: string + nickname?: string + uid?: string + type?: string + account?: string +} + +@Injectable() +export class YoutubeService { + private readonly logger = new Logger(YoutubeService.name) + + constructor( + private readonly platYoutubeNatsApi: PlatYoutubeNatsApi, + private readonly accountService: AccountService, + private readonly redisService: RedisService, + ) {} + + /** + * 返回登录授权结果 + * @param taskId + * @returns + */ + async getAuthTaskStatus(taskId: string) { + const authTaskStatus = await this.redisService.getJson( + `youtube:authTask:${taskId}`, + ) + + this.logger.log(`--youtube authTaskStatus:-- ${authTaskStatus}`) + return authTaskStatus + } + + /** + * 检查登陆状态是否过期 + * @param accountId + * @returns + */ + async checkAccountAuthStatus(accountId: string) { + const res = await this.platYoutubeNatsApi.getAccountAuthInfo(accountId) + return res + } + + /** + * 刷新令牌token + * + */ + + async refreshToken(accountId: string) { + return await this.platYoutubeNatsApi.refreshToken(accountId) + } + + /** + * 获取视频类别列表 + * @param accountId 账户ID + * @param id 类别ID(可选) + * @param regionCode 地区代码(可选) + * @returns 视频类别列表 + */ + async getVideoCategories( + accountId: string, + id?: string, + regionCode?: string, + ) { + // this.logger.log(accountId, id, regionCode); + const { code, data, message } + = await this.platYoutubeNatsApi.getVideoCategories( + accountId, + id, + regionCode, + ) + this.logger.log(code, data, message) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 获取视频列表 + * @param accountId 账户ID + * @param id 视频ID(可选) + * @param myRating 是否获取我评分的视频(可选) + * @param maxResults 最大返回结果数(可选) + * @param pageToken 分页令牌(可选) + * @returns 视频列表 + */ + async getVideosList( + accountId: string, + chart?: string, + id?: string, + myRating?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.getVideosList( + accountId, + chart, + id, + myRating, + maxResults, + pageToken, + ) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 上传整个视频到YouTube + * @param accountId 账户ID + * @param fileBuffer 视频文件Buffer + * @param fileName 文件名 + * @param title 视频标题 + * @param description 视频描述 + * @param privacyStatus 隐私状态(public, private, unlisted) + * @param keywords 关键词(可选) + * @param categoryId 视频类别ID(可选) + * @param publishAt 发布时间(可选) + * @returns 上传结果 + */ + async uploadVideo( + accountId: string, + fileBuffer: Buffer, + fileName: string, + title: string, + description: string, + privacyStatus: string, + keywords?: string, + categoryId?: string, + publishAt?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.uploadVideo( + accountId, + fileBuffer, + fileName, + title, + description, + privacyStatus, + keywords, + categoryId, + publishAt, + ) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 初始化视频分片上传 + * @param accountId 账户ID + * @param title 视频标题 + * @param description 视频描述 + * @param keywords 关键词(可选) + * @param categoryId 视频类别ID(可选) + * @param privacyStatus 隐私状态(public, private, unlisted)(可选) + * @param publishAt 发布时间(可选) + * @param contentLength 视频文件总大小,字节数(可选) + * @returns 上传会话信息 + */ + async initVideoUpload( + accountId: string, + title: string, + description: string, + keywords?: string, + categoryId?: string, + privacyStatus?: string, + publishAt?: string, + contentLength?: number, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.initVideoUpload( + accountId, + title, + description, + keywords, + categoryId, + privacyStatus, + publishAt, + contentLength, + ) + if (code) + throw new AppException(code, message) + return data + } + + /** + * 上传视频分片 + * @param accountId 账户ID + * @param file 分片数据 + * @param uploadToken 上传令牌 + * @param partNumber 分片序号 + * @returns 上传结果 + */ + async uploadVideoPart( + accountId: string, + file: Buffer, + uploadToken: string, + partNumber: number, + ) { + // 将Buffer转换为base64字符串以避免NATS序列化问题 + const fileBase64 = file.toString('base64') + + const { code, data, message } + = await this.platYoutubeNatsApi.uploadVideoPart( + accountId, + fileBase64, + uploadToken, + partNumber, + ) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 完成视频上传 + * @param accountId 账户ID + * @param uploadToken 上传令牌 + * @returns 完成结果 + */ + async videoComplete( + accountId: string, + uploadToken: string, + totalSize: number, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.videoComplete( + accountId, + uploadToken, + totalSize, + ) + if (code) + throw new AppException(code, message) + + return data + } + + /** + * 获取评论列表 + * @param accountId 账户ID + * @param id 评论ID + * @param parentId 父评论ID + * @param maxResults 最大返回结果数 + * @param pageToken 分页令牌 + * @returns 评论列表 + */ + async getCommentsList( + accountId: string, + id?: string, + parentId?: string, + maxResults?: number, + pageToken?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.getCommentsList( + accountId, + id, + parentId, + maxResults, + pageToken, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 创建顶级评论(评论会话) + async insertCommentThreads( + accountId: string, + channelId: string, + videoId: string, + textOriginal: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.insertCommentThreads( + accountId, + channelId, + videoId, + textOriginal, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 获取评论会话列表 + async getCommentThreadsList( + accountId: string, + allThreadsRelatedToChannelId?: string, + id?: string, + videoId?: string, + maxResults?: number, + pageToken?: string, + order?: string, + searchTerms?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.getCommentThreadsList( + accountId, + allThreadsRelatedToChannelId, + id, + videoId, + maxResults, + pageToken, + order, + searchTerms, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 创建二级评论 + async insertComment( + accountId: string, + parentId?: string, + textOriginal?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.insertComment( + accountId, + textOriginal, + parentId, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 更新评论 + async updateComment(accountId: string, id: string, textOriginal: string) { + const { code, data, message } = await this.platYoutubeNatsApi.updateComment( + accountId, + id, + textOriginal, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 设置评论会话的审核状态 + async setCommentThreadsModerationStatus( + accountId: string, + id: string, + moderationStatus: string, + banAuthor?: boolean, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.setModerationStatusComments( + accountId, + id, + moderationStatus, + banAuthor, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 删除评论 + async deleteComment(accountId: string, id: string) { + const { code, data, message } = await this.platYoutubeNatsApi.deleteComment( + accountId, + id, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 获取视频评分 + async getVideoRate(accountId: string, id: string) { + const { code, data, message } = await this.platYoutubeNatsApi.getVideoRate( + accountId, + id, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 设置视频评分 + async setVideoRate(accountId: string, id: string, rating: string) { + const { code, data, message } = await this.platYoutubeNatsApi.setVideoRate( + accountId, + id, + rating, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 删除视频 + async deleteVideo(accountId: string, id: string) { + const { code, data, message } = await this.platYoutubeNatsApi.deleteVideo( + accountId, + id, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 更新视频 + async updateVideo( + accountId: string, + id: string, + title: string, + categoryId: string, + defaultLanguage?: string, + description?: string, + privacyStatus?: string, + tags?: string, + publishAt?: string, + recordingDate?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.updateVideo( + accountId, + id, + title, + categoryId, + defaultLanguage, + description, + privacyStatus, + tags, + publishAt, + recordingDate, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 创建播放列表 + async createPlaylist( + accountId: string, + title: string, + description?: string, + privacyStatus?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.createPlaylist( + accountId, + title, + description, + privacyStatus, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 更新播放列表 + async updatePlaylist( + accountId: string, + id: string, + title: string, + description?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.updatePlaylist( + accountId, + id, + title, + description, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 删除播放列表 + async deletePlaylist(accountId: string, id: string) { + const { code, data, message } + = await this.platYoutubeNatsApi.deletePlaylist(accountId, id) + if (code) + throw new AppException(code, message) + + return data + } + + // 获取播放列表 + async getPlayList( + accountId: string, + channelId?: string, + id?: string, + mine?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.getPlayList( + accountId, + channelId, + id, + mine, + maxResults, + pageToken, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 插入播放列表项 + async insertPlayListItems( + accountId: string, + id: string, + resourceId: string, + position?: number, + note?: string, + startAt?: string, + endAt?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.insertPlayListItems( + accountId, + id, + resourceId, + position, + note, + startAt, + endAt, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 获取播放列表项 + async getPlayListItems( + accountId: string, + id?: string, + playlistId?: string, + maxResults?: number, + pageToken?: string, + videoId?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.getPlayListItems( + accountId, + id, + playlistId, + maxResults, + pageToken, + videoId, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 更新播放列表项 + async updatePlayListItems( + accountId: string, + id: string, + playlistId: string, + resourceId: string, + position?: number, + note?: string, + startAt?: string, + endAt?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.updatePlayListItems( + accountId, + id, + playlistId, + resourceId, + position, + note, + startAt, + endAt, + ) + if (code) + throw new AppException(code, message) + + return data + } + + // 删除播放列表项 + async deletePlayListItems(accountId: string, id: string) { + const { code, data, message } + = await this.platYoutubeNatsApi.deletePlayListItems(accountId, id) + if (code) + throw new AppException(code, message) + return data + } + + // 获取频道列表 + async getChannelsList( + accountId: string, + forHandle?: string, + forUsername?: string, + id?: string, + mine?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.getChannelsList( + accountId, + forHandle, + forUsername, + id, + mine, + maxResults, + pageToken, + ) + if (code) + throw new AppException(code, message) + return data + } + + // 获取频道板块列表 + async getChannelsSectionsList( + accountId: string, + channelId?: string, + id?: string, + mine?: boolean, + ) { + const { code, data, message } + = await this.platYoutubeNatsApi.getChannelsSectionsList( + accountId, + channelId, + id, + mine, + ) + if (code) + throw new AppException(code, message) + return data + } + + async getCommonParams() { + return { + regionCode: getRegionCodes(), + } + // return await this.redisService.get( + // `youtube:common:params`, + // true, + // ) + } + + // 更新账号 channelId + async updateChannelId(accountId: string) { + const accountInfo = await this.accountService.getAccountByParam({ account: accountId }) + + const channelInfo = await this.getChannelsList(accountId, undefined, undefined, undefined, true) + + const fetchedChannelId = channelInfo.id + + if (fetchedChannelId && typeof fetchedChannelId === 'string' && fetchedChannelId.trim() !== '') { + if (accountInfo) { + const currentChannelId = accountInfo.channelId || '' + if (currentChannelId !== fetchedChannelId) { + this.logger.log(`更新频道 channelId: ${currentChannelId} -> ${fetchedChannelId}`) + await this.accountService.updateAccountInfoById( + accountInfo.id, + { channelId: fetchedChannelId }, + ) + } + } + } + return fetchedChannelId + } + + /** + * YouTube搜索接口 + * @param accountId 账号ID + * @param forMine 是否搜索我的内容 + * @param maxResults 最大结果数 + * @param order 排序方法 + * @param pageToken 分页令牌 + * @param publishedBefore 发布时间之前 + * @param publishedAfter 发布时间之后 + * @param q 搜索查询字词 + * @param type 搜索类型 + * @param videoCategoryId 视频类别ID + * @returns 搜索结果 + */ + async search( + accountId: string, + forMine?: boolean, + maxResults?: number, + order?: string, + pageToken?: string, + publishedBefore?: string, + publishedAfter?: string, + q?: string, + type?: string, + videoCategoryId?: string, + ) { + const { code, data, message } = await this.platYoutubeNatsApi.search( + accountId, + forMine, + maxResults, + order, + pageToken, + publishedBefore, + publishedAfter, + q, + type, + videoCategoryId, + ) + + if (code) + throw new AppException(code, message) + + return data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/cloud.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/cloud.module.ts new file mode 100644 index 000000000..96074169e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/cloud.module.ts @@ -0,0 +1,33 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { AnsibleModule } from '@yikart/ansible' +import { MongodbModule } from '@yikart/mongodb' +import { RedlockModule } from '@yikart/redlock' +import { UCloudModule } from '@yikart/ucloud' +import { config } from '../config' +import { HelpersModule } from './common/helpers/helpers.module' +import { ConsumersModule } from './consumers/consumers.module' +import { BrowserProfileModule } from './core/browser-profile' +import { CloudInstanceModule } from './core/cloud-instance' +import { CloudSpaceModule } from './core/cloud-space' +import { MultiloginAccountModule } from './core/multilogin-account' +import { SchedulerModule } from './scheduler' + +@Global() +@Module({ + imports: [ + HttpModule, + MongodbModule.forRoot(config.mongodb), + UCloudModule.forRoot(config.ucloud), + RedlockModule.forRoot(config.redlock), + AnsibleModule.forRoot(config.ansible), + HelpersModule, + MultiloginAccountModule, + CloudSpaceModule, + BrowserProfileModule, + CloudInstanceModule, + SchedulerModule, + ConsumersModule, + ], +}) +export class CloudModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/cloud-instance.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/cloud-instance.enum.ts new file mode 100644 index 000000000..032e2a150 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/cloud-instance.enum.ts @@ -0,0 +1,6 @@ +export enum CloudInstanceStatus { + Creating = 'Creating', + Running = 'Running', + Stopped = 'Stopped', + Error = 'Error', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/index.ts new file mode 100644 index 000000000..02bde4559 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/index.ts @@ -0,0 +1,4 @@ +export * from './cloud-instance.enum' +export * from './job-name.enum' +export * from './queue.enum' +export * from './redlock-key.enum' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/job-name.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/job-name.enum.ts new file mode 100644 index 000000000..d72728bf1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/job-name.enum.ts @@ -0,0 +1,5 @@ +export enum JobName { + ConfigureCloudspace = 'configure-cloudspace', + CheckCloudspaceExpiration = 'check-cloudspace-expiration', + TerminateExpiredCloudspace = 'terminate-expired-cloudspace', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/queue.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/queue.enum.ts new file mode 100644 index 000000000..56bba681b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/queue.enum.ts @@ -0,0 +1,4 @@ +export enum QueueName { + CloudspaceConfigure = 'cloudspace-configure', + CloudspaceExpiration = 'cloudspace-expiration', +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/redlock-key.enum.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/redlock-key.enum.ts new file mode 100644 index 000000000..1e2fc0530 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/enums/redlock-key.enum.ts @@ -0,0 +1,7 @@ +export class RedlockKey { + static CloudSpaceConfigTask = 'scheduler:cloudSpace-config-task' + static CloudSpaceExpirationTask = 'scheduler:cloudSpace-expiration-task' + static EnvConfig: (envId: string) => string = (envId: string) => ( + `scheduler:env-config:${envId}` + ) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/helpers.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/helpers.module.ts new file mode 100644 index 000000000..8e7e5c3e2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/helpers.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common' +import { MultiloginHelper } from './multilogin.helper' + +@Module({ + providers: [MultiloginHelper], + exports: [MultiloginHelper], +}) +@Global() +export class HelpersModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/index.ts new file mode 100644 index 000000000..fef56a629 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/index.ts @@ -0,0 +1 @@ +export * from './multilogin.helper' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/multilogin.helper.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/multilogin.helper.ts new file mode 100644 index 000000000..d1d3174d0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/common/helpers/multilogin.helper.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common' +import { MultiloginAccountRepository } from '@yikart/mongodb' +import { MultiloginClient } from '@yikart/multilogin' +import { config } from '../../../config' + +interface Account { + id: string + email: string + password: string + token?: string +} + +@Injectable() +export class MultiloginHelper { + private readonly accounts: Map = new Map() + private readonly config = config.multilogin + + constructor( + private readonly multiloginAccountRepo: MultiloginAccountRepository, + ) {} + + async withAccount(config: Account): Promise { + const account = this.accounts.get(config) + if (account) { + return account + } + + const client = new MultiloginClient({ + ...config, + ...this.config, + onTokenRefresh: async (token: string) => { + config.token = token + await this.multiloginAccountRepo.updateById(config.id, { token }) + }, + }) + this.accounts.set(config, client) + return client + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-config.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-config.consumer.ts new file mode 100644 index 000000000..632cdde78 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-config.consumer.ts @@ -0,0 +1,315 @@ +import * as fs from 'node:fs' +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { AnsibleService } from '@yikart/ansible' +import { AppException, ResponseCode } from '@yikart/common' +import { BrowserProfileRepository, CloudSpace, CloudSpaceRepository, CloudSpaceStatus } from '@yikart/mongodb' +import { Job } from 'bullmq' +import * as yaml from 'js-yaml' +import * as jwt from 'jsonwebtoken' +import { config } from '../../config' +import { UserService } from '../../user/user.service' +import { QueueName } from '../common/enums' +import { MultiloginAccountService } from '../core/multilogin-account' + +interface ConfigureCloudSpaceJobData { + cloudSpaceId: string +} + +@Processor(QueueName.CloudspaceConfigure) +export class CloudSpaceConfigConsumer extends WorkerHost { + private readonly logger = new Logger(CloudSpaceConfigConsumer.name) + + constructor( + private readonly cloudSpaceRepository: CloudSpaceRepository, + private readonly browserProfileRepository: BrowserProfileRepository, + private readonly multiloginAccountService: MultiloginAccountService, + private readonly ansibleService: AnsibleService, + private readonly userService: UserService, + ) { + super() + } + + async process(job: Job) { + const { cloudSpaceId } = job.data + this.logger.log(`开始处理配置任务: ${cloudSpaceId}`) + + const cloudSpace = await this.cloudSpaceRepository.getById(cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + + // 更新状态为Configuring + await this.cloudSpaceRepository.updateById(cloudSpaceId, { + status: CloudSpaceStatus.Configuring, + }) + + try { + await this.deployBrowserAgent(cloudSpace) + + await this.cloudSpaceRepository.updateById(cloudSpaceId, { + status: CloudSpaceStatus.Ready, + }) + } + catch (error) { + this.logger.error({ + job, + error, + }) + + await this.cloudSpaceRepository.updateById(cloudSpaceId, { + status: CloudSpaceStatus.Error, + }) + + throw error + } + } + + private async deployBrowserAgent(cloudSpace: CloudSpace) { + this.logger.log(`Deploying browser agent for cloudSpace ${cloudSpace.id}`) + + const profile = await this.browserProfileRepository.getByCloudSpaceId(cloudSpace.id) + if (!profile) { + throw new AppException(ResponseCode.BrowserProfileNotFound) + } + + const multiloginAccount = await this.multiloginAccountService.getById(profile.accountId) + + const dynamicInventory = { + all: { + children: { + browser_hosts: { + hosts: { + [`browser-worker-${cloudSpace.id}`]: { + ansible_host: cloudSpace.ip, + ansible_user: 'ubuntu', + ansible_ssh_pass: cloudSpace.password, + ansible_python_interpreter: '/usr/bin/python3', + }, + }, + }, + }, + }, + } + + const user = await this.userService.getUserInfoById(cloudSpace.userId) + + if (!user) { + throw new AppException(ResponseCode.UserNotFound) + } + + const token = jwt.sign( + { + id: user.id, + mail: user.mail, + name: user.name, + }, + config.auth.secret, + { + algorithm: 'HS256', + expiresIn: config.auth.expiresIn, + }, + ) + + const localStorageValue = { + state: { + token, + userInfo: user, + }, + version: 0, + } + + const taskConfig = { + multilogin: multiloginAccount, + folderId: config.multilogin.folderId, + profileId: profile.profileId, + windows: [ + { + url: `https://aitoearn.ai/accounts?spaceId=${cloudSpace.accountGroupId}`, + localStorage: [ + { + name: 'User', + value: JSON.stringify(localStorageValue), + }, + ], + }, + { + url: 'https://ping0.cc/', + }, + ], + } + + // 创建临时文件 + const timestamp = Date.now() + const tempInventoryPath = `/tmp/inventory-${cloudSpace.id}-${timestamp}.yml` + const tempTaskConfigPath = `/tmp/task-${cloudSpace.id}-${timestamp}.json` + + const inventoryYaml = yaml.dump(dynamicInventory) + const taskConfigJson = JSON.stringify(taskConfig, null, 2) + + fs.writeFileSync(tempInventoryPath, inventoryYaml) + fs.writeFileSync(tempTaskConfigPath, taskConfigJson) + + const playbookContent = [ + { + name: 'Deploy Browser Automation Worker from GitHub Release', + hosts: 'browser_hosts', + become: true, + vars: { + app_name: 'browser-automation-worker', + app_dir: '/opt/browser-automation', + github_repo: config.github.repo, + task_config_file: tempTaskConfigPath, + github_token: config.github.token, + }, + tasks: [ + { + name: '安装系统依赖', + apt: { + name: ['curl', 'ca-certificates', 'gnupg'], + state: 'present', + update_cache: true, + }, + }, + { + name: '添加 NodeSource v22.x 软件源', + shell: 'curl -fsSL https://deb.nodesource.com/setup_22.x | bash -', + args: { + creates: '/etc/apt/sources.list.d/nodesource.list', + }, + register: 'nodesource_setup', + changed_when: `'## Run \`sudo apt-get install -y nodejs\` to install Node.js' in nodesource_setup.stdout`, + }, + { + name: '安装 Node.js v22 (从 NodeSource 源)', + apt: { + name: ['nodejs'], + state: 'present', + update_cache: '{{ nodesource_setup.changed }}', + }, + }, + { + name: '获取最新 Release 版本', + uri: { + url: 'https://api.github.com/repos/{{ github_repo }}/releases/latest', + method: 'GET', + headers: { + Authorization: 'token {{ github_token }}', + }, + }, + register: 'release_info', + }, + { + name: '设置版本变量', + set_fact: { + release_version: '{{ release_info.json.tag_name }}', + asset_filename: '{{ app_name }}-{{ release_info.json.tag_name }}.tar.gz', + }, + }, + { + name: '从 Release 信息中提取资产下载 URL', + set_fact: { + asset_download_url: `{{ (release_info.json.assets | selectattr('name', 'equalto', asset_filename) | list | first).url }}`, + }, + }, + { + name: '安装 pnpm', + shell: 'npm install -g pnpm', + args: { + creates: '/usr/local/bin/pnpm', + }, + }, + { + name: '创建应用目录', + file: { + path: '{{ app_dir }}', + state: 'directory', + }, + }, + { + name: '清理旧的应用文件', + file: { + path: '{{ app_dir }}/{{ app_name }}', + state: 'absent', + }, + }, + { + name: '创建应用子目录', + file: { + path: '{{ app_dir }}/{{ app_name }}', + state: 'directory', + }, + }, + { + name: '下载应用包', + shell: `curl --fail -L -H "Authorization: Bearer {{ github_token }}" -H "Accept: application/octet-stream" -o {{ app_dir }}/{{ asset_filename }} "{{ asset_download_url }}"`, + }, + { + name: '解压应用包', + unarchive: { + src: '{{ app_dir }}/{{ asset_filename }}', + dest: '{{ app_dir }}/{{ app_name }}', + remote_src: true, + }, + }, + { + name: '安装生产依赖', + shell: 'pnpm install --prod', + args: { + chdir: '{{ app_dir }}/{{ app_name }}', + }, + }, + { + name: '复制任务配置文件', + copy: { + src: '{{ task_config_file }}', + dest: '{{ app_dir }}/task.json', + }, + when: 'task_config_file is defined', + }, + { + name: '启动应用', + shell: 'node src/main.js --config {{ app_dir }}/task.json > {{ app_dir }}/app.log', + args: { + chdir: '{{ app_dir }}/{{ app_name }}', + }, + }, + { + name: '清理下载的压缩包', + file: { + path: '{{ app_dir }}/{{ app_name }}-{{ release_version }}.tar.gz', + state: 'absent', + }, + }, + ], + }, + ] + + const tempPlaybookPath = `/tmp/playbook-${cloudSpace.id}-${timestamp}.yml` + const playbookYaml = yaml.dump(playbookContent) + fs.writeFileSync(tempPlaybookPath, playbookYaml) + + const result = await this.ansibleService.runPlaybook(tempPlaybookPath, { + inventory: tempInventoryPath, + sshCommonArgs: '-o ControlMaster=no -o StrictHostKeyChecking=no', + }) + + if (fs.existsSync(tempInventoryPath)) { + fs.unlinkSync(tempInventoryPath) + } + if (fs.existsSync(tempTaskConfigPath)) { + fs.unlinkSync(tempTaskConfigPath) + } + if (fs.existsSync(tempPlaybookPath)) { + fs.unlinkSync(tempPlaybookPath) + } + + if (!result.success) { + throw new AppException(ResponseCode.CloudSpaceCreationFailed) + } + + await this.cloudSpaceRepository.updateById(cloudSpace.id, { + status: CloudSpaceStatus.Ready, + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-expiration.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-expiration.consumer.ts new file mode 100644 index 000000000..fb5edd16d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/cloud-space-expiration.consumer.ts @@ -0,0 +1,53 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { CloudSpaceRepository, CloudSpaceStatus } from '@yikart/mongodb' +import { Job } from 'bullmq' +import { QueueName } from '../common/enums' +import { CloudSpaceService } from '../core/cloud-space' + +interface TerminateExpiredCloudSpaceJobData { + cloudSpaceId: string +} + +@Processor(QueueName.CloudspaceExpiration) +export class CloudSpaceExpirationConsumer extends WorkerHost { + private readonly logger = new Logger(CloudSpaceExpirationConsumer.name) + + constructor( + private readonly cloudSpaceRepository: CloudSpaceRepository, + private readonly cloudSpaceService: CloudSpaceService, + ) { + super() + } + + async process(job: Job) { + const { cloudSpaceId } = job.data + this.logger.log(`开始处理过期云空间终止任务: ${cloudSpaceId}`) + + const cloudSpace = await this.cloudSpaceRepository.getById(cloudSpaceId) + if (!cloudSpace) { + this.logger.warn(`云空间 ${cloudSpaceId} 不存在,跳过处理`) + return + } + + const now = new Date() + const expiredAt = cloudSpace.expiredAt + + if (expiredAt > now) { + this.logger.warn( + `云空间 ${cloudSpaceId} 尚未过期 (过期时间: ${expiredAt.toISOString()}),跳过处理`, + ) + return + } + + if (cloudSpace.status === CloudSpaceStatus.Terminated) { + return + } + + await this.cloudSpaceService.terminateCloudSpace(cloudSpaceId) + + this.logger.log( + `云空间 ${cloudSpaceId} 已成功终止,实例 ${cloudSpace.instanceId} 已删除`, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/consumers.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/consumers.module.ts new file mode 100644 index 000000000..69a683f89 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/consumers/consumers.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common' +import { BrowserProfileModule } from '../core/browser-profile' +import { CloudInstanceModule } from '../core/cloud-instance' +import { CloudSpaceModule } from '../core/cloud-space' +import { MultiloginAccountModule } from '../core/multilogin-account' +import { CloudSpaceConfigConsumer } from './cloud-space-config.consumer' +import { CloudSpaceExpirationConsumer } from './cloud-space-expiration.consumer' + +@Module({ + imports: [ + BrowserProfileModule, + CloudInstanceModule, + CloudSpaceModule, + MultiloginAccountModule, + ], + providers: [CloudSpaceConfigConsumer, CloudSpaceExpirationConsumer], + exports: [], +}) +export class ConsumersModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.controller.ts new file mode 100644 index 000000000..70e49c3ef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.controller.ts @@ -0,0 +1,20 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { + ListBrowserProfilesDto, +} from './browser-profile.dto' +import { BrowserProfileService } from './browser-profile.service' +import { + BrowserProfileListVo, +} from './browser-profile.vo' + +@Controller() +export class BrowserProfileController { + constructor(private readonly browserProfileService: BrowserProfileService) {} + + // @NatsMessagePattern('cloud-space.profile.list') + @Post('cloud-space/profile/list') + async listProfiles(@Body() dto: ListBrowserProfilesDto): Promise { + const [profiles, total] = await this.browserProfileService.listProfiles(dto) + return new BrowserProfileListVo(profiles, total, dto) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.dto.ts new file mode 100644 index 000000000..f6a6eb727 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.dto.ts @@ -0,0 +1,11 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import { z } from 'zod' + +export const listBrowserProfilesSchema = z.object({ + accountId: z.string().optional(), + profileId: z.string().optional(), + cloudSpaceId: z.string().optional(), + ...PaginationDtoSchema.shape, +}) + +export class ListBrowserProfilesDto extends createZodDto(listBrowserProfilesSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.module.ts new file mode 100644 index 000000000..6abdf8577 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { MongodbModule } from '@yikart/mongodb' +import { BrowserProfileController } from './browser-profile.controller' +import { BrowserProfileService } from './browser-profile.service' + +@Module({ + imports: [MongodbModule], + controllers: [BrowserProfileController], + providers: [BrowserProfileService], + exports: [BrowserProfileService], +}) +export class BrowserProfileModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.service.ts new file mode 100644 index 000000000..047593f46 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { BrowserProfileRepository } from '@yikart/mongodb' +import { + ListBrowserProfilesDto, +} from './browser-profile.dto' + +@Injectable() +export class BrowserProfileService { + constructor(private readonly browserProfileRepository: BrowserProfileRepository) {} + + async listProfiles(dto: ListBrowserProfilesDto) { + return await this.browserProfileRepository.listWithPagination(dto) + } + + async getProfileById(profileId: string) { + const profile = await this.browserProfileRepository.getById(profileId) + if (!profile) { + throw new AppException(ResponseCode.BrowserProfileNotFound) + } + return profile + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.vo.ts new file mode 100644 index 000000000..04c23c2e0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/browser-profile.vo.ts @@ -0,0 +1,18 @@ +import { createPaginationVo, createZodDto } from '@yikart/common' +import z from 'zod' + +// 浏览器Profile VO +export const browserProfileVoSchema = z.object({ + id: z.string(), + accountId: z.string(), + profileId: z.string(), + cloudSpaceId: z.string().optional(), + config: z.record(z.string(), z.unknown()), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export class BrowserProfileVo extends createZodDto(browserProfileVoSchema, 'BrowserProfileVo') {} + +// Profile列表分页VO +export class BrowserProfileListVo extends createPaginationVo(browserProfileVoSchema, 'BrowserProfileListVo') {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/index.ts new file mode 100644 index 000000000..43a4cc69c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/browser-profile/index.ts @@ -0,0 +1,4 @@ +export * from './browser-profile.controller' +export * from './browser-profile.dto' +export * from './browser-profile.module' +export * from './browser-profile.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.module.ts new file mode 100644 index 000000000..6812050d1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { UCloudModule } from '@yikart/ucloud' +import { config } from '../../../config' +import { CloudInstanceService } from './cloud-instance.service' + +@Module({ + imports: [UCloudModule.forRoot(config.ucloud)], + providers: [CloudInstanceService], + exports: [CloudInstanceService], +}) +export class CloudInstanceModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.service.ts new file mode 100644 index 000000000..e3c5cd871 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/cloud-instance.service.ts @@ -0,0 +1,146 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, generateSecurePassword, ResponseCode } from '@yikart/common' +import { CloudSpaceRegion } from '@yikart/mongodb' +import { CreateULHostInstanceRequest, UCloudService, UHostIPSet, ULHostState } from '@yikart/ucloud' +import { bufferCount, concatMap, from, lastValueFrom, mergeMap, toArray } from 'rxjs' +import { config } from '../../../config' +import { CloudInstanceStatus } from '../../common/enums' + +export interface InstanceRuntime { + instanceId: string + providerStatus: ULHostState + status: CloudInstanceStatus + ip?: string +} + +@Injectable() +export class CloudInstanceService { + private readonly logger = new Logger(CloudInstanceService.name) + + constructor(private readonly ucloudService: UCloudService) {} + + async createInstance(region: CloudSpaceRegion, month: number, name?: string) { + const password = generateSecurePassword() + const request: CreateULHostInstanceRequest = { + ProjectId: config.ucloud.projectId, + Region: region, + ImageId: config.ucloud.imageId, + BundleId: config.ucloud.bundleId, + // ChargeType: 'Month', + Quantity: month, + Password: btoa(password), + Name: name, + } + + const response = await this.ucloudService.ulHost.createULHostInstance(request) + + if (response.RetCode !== 0) { + throw new AppException(ResponseCode.UCloudInstanceCreationFailed, response.Message) + } + + const instanceInfo = await this.getInstanceWithIp(response.ULHostId, region) + return { + password, + ...instanceInfo, + } + } + + async getInstanceStatus(instanceId: string, region: CloudSpaceRegion) { + const response = await this.ucloudService.ulHost.describeULHostInstance({ + Region: region, + ULHostIds: [instanceId], + }) + + if (response.RetCode !== 0 || !response.ULHostInstanceSets.length) { + throw new AppException(ResponseCode.UCloudInstanceNotFound) + } + + const instance = response.ULHostInstanceSets[0] + const ip = this.extractIp(instance.IPSet) + + return { + id: instance.ULHostId, + status: this.mapUCloudStatus(instance.State), + expiredAt: instance.ExpireTime ? new Date(instance.ExpireTime * 1000) : undefined, + ip, + } + } + + async listInstanceStatus(instanceIds: string[], region: CloudSpaceRegion): Promise { + if (instanceIds.length === 0) { + return [] + } + + return lastValueFrom( + from(instanceIds).pipe( + bufferCount(100), + concatMap(chunk => + this.ucloudService.ulHost.describeULHostInstance({ + Region: region, + ULHostIds: chunk, + }), + ), + mergeMap((response) => { + if (response.RetCode !== 0) { + this.logger.warn(`Failed to query instances in region ${region}: ${response.Message}`) + return [] + } + + return response.ULHostInstanceSets.map((instance) => { + const ip = this.extractIp(instance.IPSet) + return { + instanceId: instance.ULHostId, + providerStatus: instance.State, + status: this.mapUCloudStatus(instance.State), + ip, + } + }) + }), + toArray(), + ), + ) + } + + async deleteInstance(instanceId: string, region: CloudSpaceRegion): Promise { + const response = await this.ucloudService.ulHost.terminateULHostInstance({ + Region: region, + ULHostId: instanceId, + }) + + if (response.RetCode !== 0) { + throw new AppException(ResponseCode.UCloudInstanceDeletionFailed, response.Message) + } + } + + private async getInstanceWithIp(id: string, region: CloudSpaceRegion) { + const status = await this.getInstanceStatus(id, region) + + return { + ...status, + region, + } + } + + private mapUCloudStatus(ucloudStatus: ULHostState): CloudInstanceStatus { + const statusMapping: Record = { + [ULHostState.Initializing]: CloudInstanceStatus.Creating, + [ULHostState.Starting]: CloudInstanceStatus.Creating, + [ULHostState.Running]: CloudInstanceStatus.Running, + [ULHostState.Stopping]: CloudInstanceStatus.Stopped, + [ULHostState.Stopped]: CloudInstanceStatus.Stopped, + [ULHostState.InstallFail]: CloudInstanceStatus.Error, + [ULHostState.Rebooting]: CloudInstanceStatus.Error, + [ULHostState.Unknown]: CloudInstanceStatus.Error, + } + return statusMapping[ucloudStatus] + } + + private extractIp(ipSet: UHostIPSet[]): string | undefined { + for (const ip of ipSet) { + if (ip.Type !== 'Private') { + return ip.IP + } + } + return undefined + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/index.ts new file mode 100644 index 000000000..839f3f504 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-instance/index.ts @@ -0,0 +1,2 @@ +export * from './cloud-instance.module' +export * from './cloud-instance.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.dto.ts new file mode 100644 index 000000000..2b6f1bbbd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.dto.ts @@ -0,0 +1,48 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import { CloudSpaceRegion, CloudSpaceStatus } from '@yikart/mongodb' +import { z } from 'zod' + +export const createCloudSpaceSchema = z.object({ + userId: z.string(), + region: z.enum(CloudSpaceRegion), + profileName: z.string().optional(), + accountGroupId: z.string(), + month: z.int().min(1).default(1), +}) +export class CreateCloudSpaceDto extends createZodDto(createCloudSpaceSchema) {} + +export const listCloudSpacesSchema = z.object({ + userId: z.string().optional(), + region: z.enum(CloudSpaceRegion).optional(), + status: z.enum(CloudSpaceStatus).optional(), + ...PaginationDtoSchema.shape, +}) +export const listCloudSpacesByUserIdSchema = z.object({ + userId: z.string(), + region: z.enum(CloudSpaceRegion).optional(), + status: z.enum(CloudSpaceStatus).optional(), +}) + +export const getCloudSpaceStatusSchema = z.object({ + cloudSpaceId: z.string(), +}) +export class GetCloudSpaceStatusDto extends createZodDto(getCloudSpaceStatusSchema) {} + +export const deleteCloudSpaceSchema = z.object({ + cloudSpaceId: z.string(), +}) +export class DeleteCloudSpaceDto extends createZodDto(deleteCloudSpaceSchema) {} + +export const renewCloudSpaceSchema = z.object({ + cloudSpaceId: z.string(), + month: z.int().min(1).default(1), +}) + +export const retryCloudSpaceSchema = z.object({ + cloudSpaceId: z.string(), +}) +export class RetryCloudSpaceDto extends createZodDto(retryCloudSpaceSchema) {} + +export class ListCloudSpacesDto extends createZodDto(listCloudSpacesSchema) {} +export class ListCloudSpacesByUserIdDto extends createZodDto(listCloudSpacesByUserIdSchema) {} +export class RenewCloudSpaceDto extends createZodDto(renewCloudSpaceSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.module.ts new file mode 100644 index 000000000..d11ed2be9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { HelpersModule } from '../../common/helpers/helpers.module' +import { CloudInstanceModule } from '../cloud-instance' +import { MultiloginAccountModule } from '../multilogin-account' +import { CloudSpaceService } from './cloud-space.service' + +@Module({ + imports: [ + CloudInstanceModule, + MultiloginAccountModule, + HelpersModule, + ], + providers: [CloudSpaceService], + exports: [CloudSpaceService], +}) +export class CloudSpaceModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.service.ts new file mode 100644 index 000000000..73a84ca84 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.service.ts @@ -0,0 +1,198 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { + BrowserProfileRepository, + CloudSpaceRepository, + CloudSpaceStatus, + MultiloginAccount, + Transactional, +} from '@yikart/mongodb' +import { ProfileParameters } from '@yikart/multilogin' +import dayjs from 'dayjs' +import { config } from '../../../config' +import { MultiloginHelper } from '../../common/helpers' +import { CloudInstanceService } from '../cloud-instance' +import { MultiloginAccountService } from '../multilogin-account' +import { + CreateCloudSpaceDto, + ListCloudSpacesByUserIdDto, + ListCloudSpacesDto, + RenewCloudSpaceDto, + RetryCloudSpaceDto, +} from './cloud-space.dto' + +@Injectable() +export class CloudSpaceService { + constructor( + private readonly cloudSpaceRepository: CloudSpaceRepository, + private readonly browserProfileRepository: BrowserProfileRepository, + private readonly multiloginAccountService: MultiloginAccountService, + private readonly cloudInstanceService: CloudInstanceService, + private readonly multiloginHelper: MultiloginHelper, + ) {} + + @Transactional() + async createCloudSpace(dto: CreateCloudSpaceDto) { + const account = await this.allocateMultiloginAccount() + const instance = await this.cloudInstanceService.createInstance(dto.region, dto.month, `browser-env-${dto.userId}-${Date.now()}`) + const cloudSpace = await this.cloudSpaceRepository.create({ + userId: dto.userId, + instanceId: instance.id, + region: dto.region, + status: CloudSpaceStatus.Creating, + ip: instance.ip, + password: instance.password, + expiredAt: instance.expiredAt || dayjs().add(dto.month, 'month').toDate(), + }) + + const { id: profileId, config } = await this.createMultiloginProfile( + account, + `env-${cloudSpace.id}-${Date.now()}`, + ) + + await this.browserProfileRepository.create({ + accountId: account.id, + profileId, + cloudSpaceId: cloudSpace.id, + config, + }) + + return cloudSpace + } + + async listCloudSpaces(dto: ListCloudSpacesDto) { + return await this.cloudSpaceRepository.listWithPagination(dto) + } + + async listCloudSpacesByUserId(dto: ListCloudSpacesByUserIdDto) { + return await this.cloudSpaceRepository.listByUserId(dto) + } + + async getCloudSpaceStatus(cloudSpaceId: string) { + const cloudSpace = await this.cloudSpaceRepository.getById(cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + return cloudSpace + } + + @Transactional() + async renewCloudSpace(dto: RenewCloudSpaceDto) { + const cloudSpace = await this.cloudSpaceRepository.getById(dto.cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + + const currentExpiredAt = cloudSpace.expiredAt + + await this.cloudSpaceRepository.updateById(dto.cloudSpaceId, { + expiredAt: dayjs(currentExpiredAt).add(dto.month, 'month').toDate(), + }) + } + + @Transactional() + async retryCloudSpace(dto: RetryCloudSpaceDto) { + const cloudSpace = await this.cloudSpaceRepository.getById(dto.cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + + if (cloudSpace.status !== CloudSpaceStatus.Error) { + throw new AppException(ResponseCode.CloudSpaceNotInErrorStatus) + } + + await this.cloudSpaceRepository.updateById(dto.cloudSpaceId, { + status: CloudSpaceStatus.Configuring, + }) + } + + @Transactional() + async terminateCloudSpace(cloudSpaceId: string): Promise { + const cloudSpace = await this.cloudSpaceRepository.getById(cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + + // 如果已经是终止状态,直接返回 + if (cloudSpace.status === CloudSpaceStatus.Terminated) { + return + } + + // 更新云空间状态为已终止 + await this.cloudSpaceRepository.updateById(cloudSpaceId, { + status: CloudSpaceStatus.Terminated, + }) + + // 删除云实例 + await this.cloudInstanceService.deleteInstance( + cloudSpace.instanceId, + cloudSpace.region, + ) + } + + @Transactional() + async deleteCloudSpace(cloudSpaceId: string): Promise { + const cloudSpace = await this.cloudSpaceRepository.getById(cloudSpaceId) + if (!cloudSpace) { + throw new AppException(ResponseCode.CloudSpaceNotFound) + } + + const profiles = await this.browserProfileRepository.listByCloudSpaceId(cloudSpaceId) + for (const profile of profiles) { + await this.multiloginAccountService.decrementCurrentProfiles(profile.accountId) + } + + await this.browserProfileRepository.deleteByCloudSpaceId(cloudSpaceId) + await this.cloudSpaceRepository.deleteById(cloudSpaceId) + } + + private async allocateMultiloginAccount() { + const accounts = await this.multiloginAccountService.listAccountsWithAvailableSlots(1) + + if (accounts.length === 0) { + throw new AppException(ResponseCode.NoAvailableMultiloginAccount) + } + + const optimalAccount = accounts[0] + await this.multiloginAccountService.incrementCurrentProfiles(optimalAccount.id) + + return optimalAccount.toObject() + } + + private async createMultiloginProfile(account: MultiloginAccount, name: string) { + const client = await this.multiloginHelper.withAccount(account) + + const defaultParameters: ProfileParameters = { + flags: { + screen_masking: 'mask', + graphics_masking: 'mask', + navigator_masking: 'mask', + media_devices_masking: 'mask', + audio_masking: 'mask', + }, + storage: { + is_local: false, + }, + custom_start_urls: ['https://ping0.cc'], + } + + const defaultConfig = { + os_type: 'windows', + parameters: defaultParameters, + } as const + + const response = await client.createProfile({ + folder_id: config.multilogin.folderId, + name, + browser_type: 'mimic', + ...defaultConfig, + }) + + const profileId = response.data.ids[0] + + return { + id: profileId, + config: defaultConfig, + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.vo.ts new file mode 100644 index 000000000..0bf3e70f5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/cloud-space.vo.ts @@ -0,0 +1,24 @@ +import { createPaginationVo, createZodDto } from '@yikart/common' +import { CloudSpaceRegion, CloudSpaceStatus } from '@yikart/mongodb' +import z from 'zod' + +// 浏览器环境VO +export const cloudSpaceVoSchema = z.object({ + id: z.string(), + userId: z.string(), + accountGroupId: z.string(), + instanceId: z.string(), + region: z.enum(CloudSpaceRegion), + status: z.enum(CloudSpaceStatus), + ip: z.string(), + password: z.string().optional(), + createdAt: z.date(), + updatedAt: z.date(), + expiredAt: z.date(), + remoteUrl: z.string().optional(), +}).transform(arg => Object.assign(arg, { remoteUrl: `http://${arg.ip}:10000` })) + +export class CloudSpaceVo extends createZodDto(cloudSpaceVoSchema, 'CloudSpaceVo') {} + +// 环境列表分页VO +export class CloudSpaceListVo extends createPaginationVo(cloudSpaceVoSchema, 'CloudSpaceListVo') {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/index.ts new file mode 100644 index 000000000..b33ad4018 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/cloud-space/index.ts @@ -0,0 +1,3 @@ +export * from './cloud-space.dto' +export * from './cloud-space.module' +export * from './cloud-space.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/index.ts new file mode 100644 index 000000000..7353ea433 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/index.ts @@ -0,0 +1,4 @@ +export * from './multilogin-account.controller' +export * from './multilogin-account.dto' +export * from './multilogin-account.module' +export * from './multilogin-account.service' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.controller.ts new file mode 100644 index 000000000..4d6dc013c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { + CreateMultiloginAccountDto, + IdDto, + ListMultiloginAccountsDto, + UpdateMultiloginAccountDto, +} from './multilogin-account.dto' +import { MultiloginAccountService } from './multilogin-account.service' +import { + MultiloginAccountListVo, + MultiloginAccountVo, +} from './multilogin-account.vo' + +@Controller() +export class MultiloginAccountController { + constructor( + private readonly multiloginAccountService: MultiloginAccountService, + ) {} + + // @NatsMessagePattern('cloud-space.multilogin-account.create') + @Post('cloud-space/multilogin-account/create') + async create(@Body() createDto: CreateMultiloginAccountDto) { + const account = await this.multiloginAccountService.create(createDto) + return account + } + + // @NatsMessagePattern('cloud-space.multilogin-account.list') + @Post('cloud-space/multilogin-account/list') + async list(@Body() listDto: ListMultiloginAccountsDto): Promise { + const [accounts, total] = await this.multiloginAccountService.listWithPagination(listDto) + return new MultiloginAccountListVo(accounts, total, listDto) + } + + // @NatsMessagePattern('cloud-space.multilogin-account.getById') + @Post('cloud-space/multilogin-account/getById') + async getById(@Body() getDto: IdDto): Promise { + const account = await this.multiloginAccountService.getById(getDto.id) + return MultiloginAccountVo.create(account) + } + + // @NatsMessagePattern('cloud-space.multilogin-account.update') + @Post('cloud-space/multilogin-account/update') + async update(@Body() updateDto: UpdateMultiloginAccountDto): Promise { + const account = await this.multiloginAccountService.update(updateDto) + return MultiloginAccountVo.create(account) + } + + // @NatsMessagePattern('multilogin-account.remove') + @Post('cloud-space/multilogin-account/remove') + async remove(@Body() removeDto: IdDto): Promise { + await this.multiloginAccountService.remove(removeDto.id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.dto.ts new file mode 100644 index 000000000..2c2a34b79 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.dto.ts @@ -0,0 +1,35 @@ +import { createZodDto, PaginationDtoSchema } from '@yikart/common' +import z from 'zod' + +export const CreateMultiloginAccountDtoSchema = z.object({ + email: z.string().min(1), + password: z.string().min(1), + maxProfiles: z.number().int().min(1).default(10), +}) + +export class CreateMultiloginAccountDto extends createZodDto(CreateMultiloginAccountDtoSchema) {} + +export const UpdateMultiloginAccountDtoSchema = z.object({ + id: z.string().min(1), + email: z.string().min(1).optional(), + password: z.string().min(1).optional(), + maxProfiles: z.number().int().min(1).optional(), +}) + +export class UpdateMultiloginAccountDto extends createZodDto(UpdateMultiloginAccountDtoSchema) {} + +export const ListMultiloginAccountsDtoSchema = z.object({ + ...PaginationDtoSchema.shape, + email: z.string().optional(), + minMaxProfiles: z.coerce.number().int().min(1).optional(), + maxMaxProfiles: z.coerce.number().int().min(1).optional(), + hasAvailableSlots: z.coerce.boolean().optional(), +}) + +export class ListMultiloginAccountsDto extends createZodDto(ListMultiloginAccountsDtoSchema) {} + +export const IdDtoSchema = z.object({ + id: z.string().min(1), +}) + +export class IdDto extends createZodDto(IdDtoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.module.ts new file mode 100644 index 000000000..de0689b70 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { MultiloginAccountController } from './multilogin-account.controller' +import { MultiloginAccountService } from './multilogin-account.service' + +@Module({ + controllers: [MultiloginAccountController], + providers: [MultiloginAccountService], + exports: [MultiloginAccountService], +}) +export class MultiloginAccountModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.service.ts new file mode 100644 index 000000000..f38950f2b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.service.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { MultiloginAccountRepository } from '@yikart/mongodb' +import { + CreateMultiloginAccountDto, + ListMultiloginAccountsDto, + UpdateMultiloginAccountDto, +} from './multilogin-account.dto' + +@Injectable() +export class MultiloginAccountService { + constructor( + private readonly multiloginAccountRepository: MultiloginAccountRepository, + ) {} + + /** + * 创建Multilogin账号 + */ + async create(createDto: CreateMultiloginAccountDto) { + return await this.multiloginAccountRepository.create({ + ...createDto, + currentProfiles: 0, + }) + } + + /** + * 分页列出Multilogin账号列表 + */ + async listWithPagination(dto: ListMultiloginAccountsDto) { + const [accounts, total] = await this.multiloginAccountRepository.listWithPagination(dto) + + return [accounts, total] as const + } + + /** + * 根据ID获取Multilogin账号 + */ + async getById(id: string) { + const account = await this.multiloginAccountRepository.getById(id) + if (!account) { + throw new AppException(ResponseCode.MultiloginAccountNotFound) + } + return account + } + + /** + * 更新Multilogin账号 + */ + async update(updateDto: UpdateMultiloginAccountDto) { + const { id, ...updateData } = updateDto + const account = await this.multiloginAccountRepository.updateById(id, updateData) + if (!account) { + throw new AppException(ResponseCode.MultiloginAccountNotFound) + } + return account + } + + /** + * 删除Multilogin账号 + */ + async remove(id: string) { + const account = await this.multiloginAccountRepository.deleteById(id) + if (!account) { + throw new AppException(ResponseCode.MultiloginAccountNotFound) + } + } + + /** + * 增加当前配置数 + */ + async incrementCurrentProfiles(id: string, increment = 1) { + const account = await this.getById(id) + const newCurrentProfiles = account.currentProfiles + increment + + if (newCurrentProfiles > account.maxProfiles) { + throw new AppException(ResponseCode.MultiloginAccountProfilesExceeded) + } + + return await this.multiloginAccountRepository.updateById(id, { + currentProfiles: newCurrentProfiles, + }) + } + + /** + * 减少当前配置数 + */ + async decrementCurrentProfiles(id: string, decrement = 1) { + const account = await this.getById(id) + const newCurrentProfiles = Math.max(0, account.currentProfiles - decrement) + + return await this.multiloginAccountRepository.updateById(id, { + currentProfiles: newCurrentProfiles, + }) + } + + /** + * 获取有可用槽位的账号 + */ + async listAccountsWithAvailableSlots(limit = 10) { + const [accounts] = await this.multiloginAccountRepository.listWithPagination({ + page: 1, + pageSize: limit, + hasAvailableSlots: true, + }) + return accounts + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.vo.ts new file mode 100644 index 000000000..217981df7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/core/multilogin-account/multilogin-account.vo.ts @@ -0,0 +1,19 @@ +import { createPaginationVo, createZodDto } from '@yikart/common' +import z from 'zod' + +// Multilogin账号VO +export const multiloginAccountVoSchema = z.object({ + id: z.string(), + email: z.string(), + password: z.string(), + maxProfiles: z.number(), + currentProfiles: z.number(), + token: z.string().optional(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export class MultiloginAccountVo extends createZodDto(multiloginAccountVoSchema, 'MultiloginAccountVo') {} + +// 账号列表分页VO +export class MultiloginAccountListVo extends createPaginationVo(multiloginAccountVoSchema, 'MultiloginAccountListVo') {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-expiration.scheduler.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-expiration.scheduler.ts new file mode 100644 index 000000000..13bfa8fb9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-expiration.scheduler.ts @@ -0,0 +1,61 @@ +import { InjectQueue } from '@nestjs/bullmq' +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { CloudSpaceRepository, CloudSpaceStatus } from '@yikart/mongodb' +import { Redlock } from '@yikart/redlock' +import { Queue } from 'bullmq' +import { JobName, QueueName, RedlockKey } from '../common/enums' + +@Injectable() +export class CloudSpaceExpirationScheduler { + private readonly logger = new Logger(CloudSpaceExpirationScheduler.name) + + constructor( + private readonly cloudSpaceRepository: CloudSpaceRepository, + @InjectQueue(QueueName.CloudspaceExpiration) private readonly expirationQueue: Queue, + ) {} + + /** + * 每10分钟检查一次云空间过期情况 + * 处理已过期和即将在1小时内过期的云空间 + */ + @Cron(CronExpression.EVERY_10_MINUTES) + @Redlock(RedlockKey.CloudSpaceExpirationTask) + async processCloudSpaceExpiration() { + const now = new Date() + const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000) + + this.logger.log('开始检查云空间过期情况') + + // 查找已过期和即将在1小时内过期的云空间 + const cloudSpaces = await this.cloudSpaceRepository.listByStatus({ + expiredAt: [undefined, oneHourLater], + status: CloudSpaceStatus.Ready, + }) + + if (cloudSpaces.length === 0) { + this.logger.log('没有找到需要处理的云空间') + return + } + + this.logger.log(`找到 ${cloudSpaces.length} 个需要处理的云空间`) + + for (const cloudSpace of cloudSpaces) { + const expiredAt = new Date(cloudSpace.expiredAt) + const delay = Math.max(0, Math.min(expiredAt.getTime() - now.getTime(), 60 * 60 * 1000)) + + await this.expirationQueue.add( + JobName.TerminateExpiredCloudspace, + { cloudSpaceId: cloudSpace.id }, + { + delay, + jobId: `terminate-${cloudSpace.id}`, + removeOnComplete: 10, + removeOnFail: 10, + }, + ) + } + + this.logger.log(`成功为 ${cloudSpaces.length} 个云空间创建了终止任务`) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-setup.scheduler.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-setup.scheduler.ts new file mode 100644 index 000000000..439677468 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/cloud-space-setup.scheduler.ts @@ -0,0 +1,109 @@ +import { InjectQueue } from '@nestjs/bullmq' +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { CloudSpace, CloudSpaceRegion, CloudSpaceRepository, CloudSpaceStatus } from '@yikart/mongodb' +import { Redlock } from '@yikart/redlock' +import { Job, Queue } from 'bullmq' +import { CloudInstanceStatus, JobName, QueueName, RedlockKey } from '../common/enums' +import { CloudInstanceService } from '../core/cloud-instance' + +@Injectable() +export class CloudSpaceSetupScheduler { + private readonly logger = new Logger(CloudSpaceSetupScheduler.name) + + constructor( + private readonly cloudSpaceRepository: CloudSpaceRepository, + private readonly cloudInstanceService: CloudInstanceService, + @InjectQueue(QueueName.CloudspaceConfigure) private readonly configQueue: Queue, + ) {} + + @Cron(CronExpression.EVERY_30_SECONDS) + @Redlock(RedlockKey.CloudSpaceConfigTask) + async processCloudSpaceConfiguration() { + await this.processCreatingCloudSpaces() + await this.processConfiguringCloudSpaces() + } + + private async processCreatingCloudSpaces() { + const cloudSpaces = await this.cloudSpaceRepository.listByStatus({ + status: CloudSpaceStatus.Creating, + }) + + if (cloudSpaces.length === 0) { + return + } + + this.logger.debug(`找到 ${cloudSpaces.length} 个Creating状态的环境`) + + const regionGroups = new Map() + for (const cloudSpace of cloudSpaces) { + if (!regionGroups.has(cloudSpace.region)) { + regionGroups.set(cloudSpace.region, []) + } + regionGroups.get(cloudSpace.region)!.push(cloudSpace) + } + + for (const [region, regionCloudSpaces] of regionGroups) { + const instanceIds = regionCloudSpaces.map(cs => cs.instanceId) + const instanceStatuses = await this.cloudInstanceService.listInstanceStatus(instanceIds, region) + + for (const cloudSpace of regionCloudSpaces) { + const instanceStatus = instanceStatuses.find(status => status.instanceId === cloudSpace.instanceId) + + if (!instanceStatus) { + this.logger.warn(`未找到实例 ${cloudSpace.instanceId} 的状态信息`) + continue + } + + if (instanceStatus.status === CloudInstanceStatus.Running) { + await this.enqueueConfigurationTask(cloudSpace, instanceStatus.ip) + } + } + } + } + + private async processConfiguringCloudSpaces() { + const configuringSpaces = await this.cloudSpaceRepository.listByStatus({ + status: CloudSpaceStatus.Configuring, + }) + + if (configuringSpaces.length === 0) { + return + } + + this.logger.debug(`检查 ${configuringSpaces.length} 个Configuring状态的环境`) + + for (const cloudSpace of configuringSpaces) { + await this.enqueueConfigurationTask(cloudSpace) + } + } + + private async enqueueConfigurationTask(cloudSpace: CloudSpace, newIp?: string) { + if (newIp && newIp !== cloudSpace.ip) { + await this.cloudSpaceRepository.updateById(cloudSpace.id, { ip: newIp }) + cloudSpace.ip = newIp + } + this.logger.debug({ + cloudSpace, + newIp, + }) + + const job = await this.configQueue.getJob(cloudSpace.id) as Job + + this.logger.debug({ job }) + if (job) { + const jobState = await job.getState() + + if (jobState === 'failed' || jobState === 'completed') { + await job.retry() + } + } + else { + await this.configQueue.add( + JobName.ConfigureCloudspace, + { cloudSpaceId: cloudSpace.id }, + { jobId: cloudSpace.id }, + ) + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/index.ts new file mode 100644 index 000000000..1e60668fc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/index.ts @@ -0,0 +1,2 @@ +export * from './cloud-space-setup.scheduler' +export * from './scheduler.module' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/scheduler.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/scheduler.module.ts new file mode 100644 index 000000000..3693dbfed --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/cloud/scheduler/scheduler.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common' +import { ScheduleModule } from '@nestjs/schedule' +import { CloudInstanceModule } from '../core/cloud-instance' +import { CloudSpaceModule } from '../core/cloud-space' +import { CloudSpaceExpirationScheduler } from './cloud-space-expiration.scheduler' +import { CloudSpaceSetupScheduler } from './cloud-space-setup.scheduler' + +@Module({ + imports: [ + ScheduleModule.forRoot(), + CloudInstanceModule, + CloudSpaceModule, + ], + providers: [CloudSpaceSetupScheduler, CloudSpaceExpirationScheduler], + exports: [], +}) +export class SchedulerModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/transform.interceptor.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/transform.interceptor.ts new file mode 100644 index 000000000..d0927d181 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/transform.interceptor.ts @@ -0,0 +1,72 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 15:56:08 + * @LastEditors: nevin + * @LastEditTime: 2025-02-25 00:28:07 + * @Description: 全局拦截器 慢日志打印 + */ +import { + CallHandler, + CanActivate, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common' +import { Observable } from 'rxjs' +import { map } from 'rxjs/operators' +import { HttpResult } from '../interfaces' + +@Injectable() +export class OrgGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest() + request.is_org = true + return true + } +} + +@Injectable() +export class TransformInterceptor +implements NestInterceptor> { + logger = new Logger(OrgGuard.name) + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const startTime = Date.parse(new Date().toString()) + + const ctx = context.switchToHttp() + // const response: Response = ctx.getResponse(); + const request = ctx.getRequest() + const reqUrl = request.originalUrl + + return next.handle().pipe( + map((data: T): HttpResult => { + // --------- 慢日志打印警告 STR --------- + const ruqTime = Date.parse(new Date().toString()) - startTime + if (ruqTime >= 50) { + this.logger.verbose({ + level: 'verbose', + message: `${reqUrl}::${ruqTime}ms`, + mate: ruqTime, + }) + } + // --------- 慢日志打印警告 END --------- + + // 不进行封装的返回 + if (request.is_org) + return data as any + + // 封装 + return { + data, + code: 0, + message: '请求成功', + url: reqUrl, + } + }), + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/xml.interceptor.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/xml.interceptor.ts new file mode 100644 index 000000000..9aee59a74 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interceptor/xml.interceptor.ts @@ -0,0 +1,32 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common' +import { Observable } from 'rxjs' +import { switchMap } from 'rxjs/operators' +import * as xml2js from 'xml2js' + +@Injectable() +export class XmlParseInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest() + + return new Observable((subscriber) => { + xml2js.parseString( + request.body, + { explicitArray: false }, + (err: any, result: { xml: any }) => { + if (err) + return subscriber.error(err) + request.body = result.xml || {} + subscriber.next(request) + subscriber.complete() + }, + ) + }).pipe( + switchMap(() => next.handle()), // 正确串联后续处理 + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/index.ts new file mode 100644 index 000000000..134ce1070 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/index.ts @@ -0,0 +1,2 @@ +export * from './response.interface' +export * from './table.interface' diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/response.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/response.interface.ts new file mode 100644 index 000000000..a874f1c86 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/response.interface.ts @@ -0,0 +1,13 @@ +export interface CommonResponse { + data?: T + code: number + message: string + timestamp?: number +} + +export interface HttpResult { + data?: T // 数据 + message: string // 信息 + code: number // 自定义code + url: string // 错误的url地址 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/table.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/table.interface.ts new file mode 100644 index 000000000..3fa2dc43d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/interfaces/table.interface.ts @@ -0,0 +1,6 @@ +export interface CorrectResponse { + list: T[] + pageSize: number + pageNo: number + count: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/file.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/file.util.ts new file mode 100644 index 000000000..37764c76c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/file.util.ts @@ -0,0 +1,80 @@ +import * as fs from 'node:fs' +import path from 'node:path' + +enum Type { + IMAGE = '图片', + TXT = '文档', + MUSIC = '音乐', + VIDEO = '视频', + OTHER = '其他', +} + +export function getFileType(extName: string) { + const documents = 'txt doc pdf ppt pps xlsx xls docx' + const music = 'mp3 wav wma mpa ram ra aac aif m4a' + const video = 'avi mpg mpe mpeg asf wmv mov qt rm mp4 flv m4v webm ogv ogg' + const image + = 'bmp dib pcp dif wmf gif jpg tif eps psd cdr iff tga pcd mpt png jpeg' + if (image.includes(extName)) + return Type.IMAGE + + if (documents.includes(extName)) + return Type.TXT + + if (music.includes(extName)) + return Type.MUSIC + + if (video.includes(extName)) + return Type.VIDEO + + return Type.OTHER +} + +export function getName(fileName: string) { + if (fileName.includes('.')) + return fileName.split('.')[0] + + return fileName +} + +export function getExtname(fileName: string) { + return path.extname(fileName).replace('.', '') +} + +export function getSize(bytes: number, decimals = 2) { + if (bytes === 0) + return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return `${Number.parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}` +} + +/** + * nodejs存储文件到本地 + * @param base64String + * @param path + * @param fileName + * @returns + */ +export function saveFile(base64String: string, path: string, fileName: string) { + // 文件不存在则创建文件 + if (!fs.existsSync(path)) { + fs.mkdirSync(path, { recursive: true }) + } + + return new Promise((resolve, reject) => { + fs.writeFile(path + fileName, base64String, 'base64', (err) => { + if (err) { + reject(err) + } + else { + resolve(true) + } + }) + }) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/index.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/index.ts new file mode 100644 index 000000000..9ceb6db62 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/index.ts @@ -0,0 +1,41 @@ +/* + * @Author: nevin + * @Date: 2024-07-04 15:15:28 + * @LastEditTime: 2024-11-26 18:39:44 + * @LastEditors: nevin + * @Description: 工具 + */ + +/** + * 封装一个非阻塞的sleep函数,返回一个Promise对象。 + * @param {number} milliseconds - 指定延迟的毫秒数。 + * @returns {Promise} - 一个Promise,将在指定延迟后resolve。 + */ +export function sleep(milliseconds: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, milliseconds) + }) +} + +// 封装一个获取随机的任意个数字或字母的字符串的函数 +export function getRandomString(length: number, onlyNum = false): string { + const chars = onlyNum + ? '1234567890' + : 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + let result = '' + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)) + } + return result +} + +/** + * 获取某一天 UTC 的起止时间 + * @param date 格式为 "2025-07-25" + * @returns { start: Date, end: Date } + */ +export function getDayRangeUTC(date: string): { start: Date, end: Date } { + const start = new Date(`${date} T00:00:00.000Z`) + const end = new Date(`${date} T23:59:59.999Z`) + return { start, end } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/ip.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/ip.util.ts new file mode 100644 index 000000000..59e5da346 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/ip.util.ts @@ -0,0 +1,65 @@ +import type { IncomingMessage } from 'node:http' +/** + * @module utils/ip + * @description IP utility functions + */ +import axios from 'axios' + +/* 判断IP是不是内网 */ +function isLAN(ip: string) { + ip.toLowerCase() + if (ip === 'localhost') + return true + let a_ip = 0 + if (ip === '') + return false + const aNum = ip.split('.') + if (aNum.length !== 4) + return false + a_ip += Number.parseInt(aNum[0]) << 24 + a_ip += Number.parseInt(aNum[1]) << 16 + a_ip += Number.parseInt(aNum[2]) << 8 + a_ip += Number.parseInt(aNum[3]) << 0 + a_ip = (a_ip >> 16) & 0xFFFF + return ( + a_ip >> 8 === 0x7F + || a_ip >> 8 === 0xA + || a_ip === 0xC0A8 + || (a_ip >= 0xAC10 && a_ip <= 0xAC1F) + ) +} + +export function getIp(request: IncomingMessage) { + const req = request as any + + let ip: string + = request.headers['x-forwarded-for'] + || request.headers['X-Forwarded-For'] + || request.headers['X-Real-IP'] + || request.headers['x-real-ip'] + || req?.ip + || req?.raw?.connection?.remoteAddress + || req?.raw?.socket?.remoteAddress + || undefined + if (ip && ip.split(',').length > 0) + ip = ip.split(',')[0] + + return ip +} + +export async function getIpAddress(ip: string) { + if (isLAN(ip)) + return '内网IP' + try { + let { data } = await axios.get( + `https://whois.pconline.com.cn/ipJson.jsp?ip=${ip}&json=true`, + { responseType: 'arraybuffer' }, + ) + data = new TextDecoder('gbk').decode(data) + data = JSON.parse(data) + return data.addr.trim().split(' ').at(0) + } + catch (e) { + return `第三方接口请求失败${e}` + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/is.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/is.util.ts new file mode 100644 index 000000000..a55858713 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/is.util.ts @@ -0,0 +1,4 @@ +/** 判断是否外链 */ +export function isExternal(path: string): boolean { + return /^(?:https?:|mailto:|tel:)/.test(path) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/list2tree.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/list2tree.util.ts new file mode 100644 index 000000000..6025868ab --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/list2tree.util.ts @@ -0,0 +1,83 @@ +export type TreeNode = T & { + id: number + parentId: number + children?: TreeNode[] +} + +export type ListNode = T & { + id: number + parentId: number +} + +export function list2Tree( + items: T, + parentId: number | null = null, +): TreeNode[] { + return items + .filter(item => item.parentId === parentId) + .map((item) => { + const children = list2Tree(items, item.id) + return { + ...item, + ...(children.length ? { children } : null), + } + }) +} + +/** + * 过滤树,返回列表数据 + * @param treeData + * @param key 用于过滤的字段 + * @param value 用于过滤的值 + */ +export function filterTree2List(treeData: any[], key: string | number, value: any) { + const filterChildrenTree = (resTree: any[], treeItem: { [x: string]: string | any[], children: any[] }) => { + if (treeItem[key].includes(value)) { + resTree.push(treeItem) + return resTree + } + if (Array.isArray(treeItem.children)) { + const children = treeItem.children.reduce(filterChildrenTree, []) + + const data = { ...treeItem, children } + + if (children.length) + resTree.push({ ...data }) + } + return resTree + } + return treeData.reduce(filterChildrenTree, []) +} + +/** + * 过滤树,并保留原有的结构 + * @param treeData + * @param predicate + */ +export function filterTree( + treeData: TreeNode[], + predicate: (data: T) => boolean, +): TreeNode[] { + function filter(treeData: TreeNode[]): TreeNode[] { + if (!treeData?.length) + return treeData + + return treeData.filter((data) => { + if (!predicate(data)) + return false + + data.children = filter(data.children!) + return true + }) + } + + return filter(treeData) || [] +} + +export function deleteEmptyChildren(arr: any) { + arr?.forEach((node: { children: any }) => { + if (node.children?.length === 0) + delete node.children + else deleteEmptyChildren(node.children) + }) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/map.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/map.ts new file mode 100644 index 000000000..ee220c013 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/map.ts @@ -0,0 +1,34 @@ +/* + * @Author: nevin + * @Date: 2024-07-29 11:14:20 + * @LastEditTime: 2024-07-29 11:17:33 + * @LastEditors: nevin + * @Description: 地图 + */ +export function isWithinMeters( + locus1: number[], + locus2: number[], + distanceInMeters: number, // 千米 +) { + const [lat1, lon1] = locus1 + const [lat2, lon2] = locus2 + + const R = 6371 // 地球平均半径,单位为公里 + const dLat = deg2rad(lat2 - lat1) + const dLon = deg2rad(lon2 - lon1) + const a + = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(lat1)) + * Math.cos(deg2rad(lat2)) + * Math.sin(dLon / 2) + * Math.sin(dLon / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + const distance = R * c // 距离,单位为公里 + + return distance <= distanceInMeters // 判断距离是否小于等于500米 +} + +// 辅助函数,将角度转换为弧度 +function deg2rad(deg: number) { + return deg * (Math.PI / 180) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/password.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/password.util.ts new file mode 100644 index 000000000..6967edd87 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/password.util.ts @@ -0,0 +1,64 @@ +/* + * @Author: nevin + * @Date: 2022-01-21 09:50:47 + * @LastEditors: nevin + * @LastEditTime: 2024-07-08 15:38:55 + * @Description: 认证模块-加密工具 + */ +import * as crypto from 'node:crypto' + +export interface Password { + password: string // 密码 + salt: string // 盐 +} + +/** + * 生成随机盐 + */ +function makeSalt(): string { + return crypto.randomBytes(3).toString('base64') +} + +/** + * Encrypt password + * @param password 密码 + * @param salt 密码盐 + * @returns { + * password, // 加密的密码 + * salt + * } + */ +export function encryptPassword( + password: string, + salt?: string, +): Password { + salt = salt || makeSalt() + + // 10000 代表迭代次数 16代表长度 + password = crypto + .pbkdf2Sync(password, Buffer.from(salt, 'base64'), 10000, 16, 'sha1') + .toString('base64') + return { + password, + salt, + } +} + +/** + * 校验用户信息 + * @param userPassword 用户密码 + * @param userSalt 盐值 + * @param password 密码 + * @returns + */ +export function validatePassWord( + userPassword: string, + userSalt: string, + password: string, +): boolean { + // 通过密码盐,加密传参,再与数据库里的比较,判断是否相等 + const res = encryptPassword(password, userSalt) + if (!res) + return false + return userPassword === res.password +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/time.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/time.util.ts new file mode 100644 index 000000000..fafb65b18 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/common/utils/time.util.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2024-07-22 17:57:25 + * @LastEditTime: 2024-07-22 17:59:08 + * @LastEditors: nevin + * @Description: + */ +// 获取今天的0点(午夜)时间 +export function getTodayMidnight(): Date { + const todayMidnight = new Date() + todayMidnight.setHours(0, 0, 0, 0) + + return todayMidnight +} + +// 获取今天的24点(实际上是明天的0点) +export function getTodayEnd(): Date { + const todayEnd = new Date() + todayEnd.setDate(todayEnd.getDate() + 1) + todayEnd.setHours(0, 0, 0, 0) + return todayEnd +} + +// 获取当前的秒级时间戳 +export function getCurrentTimestamp(): number { + return Math.floor(new Date().getTime() / 1000) +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/config.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/config.ts new file mode 100644 index 000000000..5e45a1d17 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/config.ts @@ -0,0 +1,213 @@ +import { aitoearnAuthConfigSchema } from '@yikart/aitoearn-auth' +import { ansibleConfigSchema } from '@yikart/ansible' +import { s3ConfigSchema } from '@yikart/aws-s3' +import { baseConfig, createZodDto, selectConfig } from '@yikart/common' +import { AiLogChannel, mongodbConfigSchema } from '@yikart/mongodb' +import { oneSignalConfigSchema } from '@yikart/one-signal' +import { redisConfigSchema } from '@yikart/redis' +import { redlockConfigSchema } from '@yikart/redlock' +import { ucloudConfigSchema } from '@yikart/ucloud' +import z from 'zod' +import { dashscopeConfigSchema } from './ai/libs/dashscope' +import { fireflycardConfigSchema } from './ai/libs/fireflycard' +import { klingConfigSchema } from './ai/libs/kling' +import { md2cardConfigSchema } from './ai/libs/md2card' +import { openaiConfigSchema } from './ai/libs/openai' +import { sora2ConfigSchema } from './ai/libs/sora2' +import { volcengineConfigSchema } from './ai/libs/volcengine' + +const mailConfigSchema = z.object({ + transport: z.object({ + host: z.string().default(''), + port: z.number().default(587), + secure: z.boolean().default(false), + auth: z.object({ + user: z.string().default(''), + pass: z.string().default(''), + }), + }), + defaults: z.object({ + from: z.string().default(''), + }), + template: z.object({ + dir: z.string().default(''), + adapter: z.any().optional(), + options: z.object({ + strict: z.boolean().default(true), + }).optional(), + }).optional(), +}) + +export const aiModelsConfigSchema = z.object({ + chat: z.array(z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + inputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + outputModalities: z.array(z.enum(['text', 'image', 'video', 'audio'])), + pricing: z.union([ + z.object({ + discount: z.string().optional(), + prompt: z.string(), + originPrompt: z.string().optional(), + completion: z.string(), + originCompletion: z.string().optional(), + image: z.string().optional(), + originImage: z.string().optional(), + audio: z.string().optional(), + originAudio: z.string().optional(), + }), + z.object({ + price: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + }), + ]), + })), + image: z.object({ + generation: z.array(z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()), + qualities: z.array(z.string()), + styles: z.array(z.string()), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + })), + edit: z.array(z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + sizes: z.array(z.string()), + pricing: z.string(), + discount: z.string().optional(), + originPrice: z.string().optional(), + maxInputImages: z.number(), + })), + }), + video: z.object({ + generation: z.array(z.object({ + name: z.string(), + description: z.string(), + summary: z.string().optional(), + logo: z.string().optional(), + tags: z.string().array().default([]), + mainTag: z.string().optional(), + channel: z.enum(AiLogChannel), + modes: z.array(z.enum(['text2video', 'image2video', 'flf2video', 'lf2video', 'multi-image2video'])), + resolutions: z.array(z.string()), + durations: z.array(z.number()), + supportedParameters: z.array(z.string()), + defaults: z.object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + }).optional(), + pricing: z.object({ + resolution: z.string().optional(), + aspectRatio: z.string().optional(), + mode: z.string().optional(), + duration: z.number().optional(), + price: z.number(), + discount: z.string().optional(), + originPrice: z.number().optional(), + }).array(), + })), + }), +}) + +export const aiConfigSchema = z.object({ + models: aiModelsConfigSchema, + openai: openaiConfigSchema, + fireflycard: fireflycardConfigSchema, + md2card: md2cardConfigSchema, + kling: z.object({ + ...klingConfigSchema.shape, + callbackUrl: z.string().optional(), + }), + volcengine: z.object({ + ...volcengineConfigSchema.shape, + callbackUrl: z.string().optional(), + }), + dashscope: z.object({ + ...dashscopeConfigSchema.shape, + callbackUrl: z.string().optional(), + }), + sora2: sora2ConfigSchema, +}) + +const AliGreenConfigSchema = z.object({ + accessKeyId: z.string().default(''), + accessKeySecret: z.string().default(''), + endpoint: z.string().default(''), +}) + +// MoreAPI配置 +const moreApiConfigSchema = z.object({ + platApiUri: z.string().default(''), + xhsCreatorUri: z.string().default(''), +}) + +export const appConfigSchema = z.object({ + ...baseConfig.shape, + auth: aitoearnAuthConfigSchema, + ucloud: z.object({ + ...ucloudConfigSchema.shape, + imageId: z.string(), + bundleId: z.string(), + }), + redis: redisConfigSchema, + multilogin: z.object({ + launcherBaseUrl: z.string().default('https://launcher.mlx.yt:45001'), + profileBaseUrl: z.string().default('https://api.multilogin.com'), + timeout: z.number().default(30000), + folderId: z.string(), + defaultUrl: z.string().default('https://example.com'), + agent: z.object({ + url: z.string().default('https://example.com'), + gitUrl: z.string(), + gitBranch: z.string(), + }), + }), + github: z.object({ + token: z.string(), + repo: z.string(), + }), + ansible: ansibleConfigSchema, + mongodb: mongodbConfigSchema, + redlock: redlockConfigSchema, + oneSignal: oneSignalConfigSchema, + awsS3: s3ConfigSchema, + mail: mailConfigSchema, + environment: z.string().default('development'), + ai: aiConfigSchema, + aliGreen: AliGreenConfigSchema, + mailBackHost: z.string(), + channelApi: z.object({ + baseUrl: z.string().default('http://localhost:3000'), + }), + taskApi: z.object({ + baseUrl: z.string().default('http://localhost:3000'), + }), + paymentApi: z.object({ + baseUrl: z.string().default('http://localhost:3000'), + }), + moreApi: moreApiConfigSchema, + statisticsDb: mongodbConfigSchema, +}) + +export class AppConfig extends createZodDto(appConfigSchema) { } + +export const config = selectConfig(AppConfig) diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/common.ts new file mode 100644 index 000000000..de1fb0c71 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/common.ts @@ -0,0 +1,214 @@ +import { UserType } from '@yikart/common' + +export enum PubStatus { + UNPUBLISH = 0, // 未发布/草稿 + RELEASED = 1, // 已发布 + FAIL = 2, // 发布失败 + PartSuccess = 3, // 部分成功 +} + +export enum PubType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 +} + +export interface PubRecord { + id: string + userId: string + accountId: string + commonCoverPath?: string + coverPath?: string + desc: string + publishTime?: Date + status: PubStatus + timingTime?: Date + title: string + type: PubType + videoPath?: string +} + +export enum MaterialType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 +} + +export enum MaterialStatus { + WAIT = 0, + SUCCESS = 1, + FAIL = -1, +} + +export interface Material { + id: string + userId: string + groupId?: string // 所属组ID + type: MaterialType + coverUrl?: string + mediaList: MaterialMedia[] + title: string + desc: string + status: MaterialStatus + option: Record + createAt: Date + updatedAt: Date +} + +export interface MaterialMedia { + url: string + type: MediaType + content?: string +} + +export interface NewMaterial { + id?: string + userId: string + userType?: UserType + taskId?: string + type: MaterialType + groupId: string // 所属组ID + coverUrl?: string + mediaList: MaterialMedia[] + title: string + desc?: string + location?: number[] + option?: Record + autoDeleteMedia?: boolean +} + +export interface NewMaterialTask { + groupId: string + num: number + aiModelTag: string + prompt: string + type: MaterialType + title?: string + desc?: string + location?: number[] + mediaGroups: string[] + coverGroup: string + option?: Record +} + +export interface MediaUrlInfo { + id?: string + mediaId: string + url: string + num: number + type: MediaType +} + +export enum MaterialTaskStatus { + WAIT = 0, + RUNNING = 1, + SUCCESS = 2, + FAIL = -1, +} + +export interface MaterialTask { + id: string + userId: string + userType: UserType + groupId: string // 所属组ID + type: MaterialType + aiModelTag: string + prompt: string // 提示词 + coverGroup?: string + mediaGroups: string[] + option?: Record // 高级设置 + title?: string + textMax?: number + desc?: string + location?: number[] + coverUrl?: string + coverUrlList: MediaUrlInfo[] // 封面数组 + mediaUrlMap: MediaUrlInfo[][] // 媒体的二维数组 + reNum: number + max?: number + language?: string + status: MaterialTaskStatus + autoDeleteMedia: boolean +} + +export interface UpMaterial { + title?: string + desc?: string + option?: Record +} + +export interface MaterialFilter { + readonly userId?: string + readonly userType?: string + readonly status?: MaterialStatus + readonly ids?: string[] + readonly title?: string + readonly groupId?: string + readonly useCount?: number +} + +export interface MaterialListByIdsFilter { + readonly ids: string[] +} + +export interface MaterialGroup { + id: string + userId: string + userType?: string + title: string + desc?: string + createAt: Date + updatedAt: Date +} + +export enum MediaType { + VIDEO = 'video', // 视频 + IMG = 'img', // 图片 +} + +export interface Media { + id: string + userId: string + userType?: string + groupId?: string // 所属组ID + materialId?: string // 所属素材ID + type: MaterialType + url: string + title?: string + desc?: string + createAt: Date + updatedAt: Date +} + +export interface NewMedia { + userId: string + userType?: string + groupId?: string // 所属组ID + materialId?: string // 所属素材ID + type: MediaType + url: string + thumbUrl?: string + title?: string + desc?: string +} + +export interface MediaGroup { + _id: string + id: string + userId: string + title: string + desc?: string + createAt: Date + updatedAt: Date + mediaList?: { list: Media[], total: number } +} +export interface NewMaterialGroup { + type: MaterialType + userId: string + userType?: UserType + name: string + readonly desc?: string +} + +export interface UpdateMaterialGroup { + name: string + readonly desc?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/content.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/content.module.ts new file mode 100644 index 000000000..0c64e6050 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/content.module.ts @@ -0,0 +1,26 @@ +import { Global, Module } from '@nestjs/common' +import { S3Module } from '@yikart/aws-s3' +import { AiModule } from '../ai/ai.module' +import { config } from '../config' +import { MaterialGenerateConsumer } from './material-generate.consumer' +import { MaterialController } from './material.controller' +import { MaterialService } from './material.service' +import { MaterialGroupController } from './materialGroup.controller' +import { MaterialGroupService } from './materialGroup.service' +import { MaterialTaskService } from './materialTask.service' +import { MediaController } from './media.controller' +import { MediaService } from './media.service' +import { MediaGroupController } from './mediaGroup.controller' +import { MediaGroupService } from './mediaGroup.service' + +@Global() +@Module({ + imports: [ + S3Module.forRoot(config.awsS3), + AiModule, + ], + controllers: [MediaController, MediaGroupController, MaterialGroupController, MaterialController], + providers: [MediaService, MediaGroupService, MaterialGroupService, MaterialService, MaterialTaskService, MaterialGenerateConsumer], + exports: [MediaService, MediaGroupService, MaterialGroupService, MaterialService, MaterialTaskService], +}) +export class ContentModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/material.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/material.dto.ts new file mode 100644 index 000000000..678ca515e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/material.dto.ts @@ -0,0 +1,82 @@ +import { ApiProperty } from '@nestjs/swagger' +import { createZodDto, TableDto } from '@yikart/common' +import { MaterialStatus, MaterialType, MediaType } from '@yikart/mongodb' +import { Expose } from 'class-transformer' +import { + IsString, +} from 'class-validator' +import { z } from 'zod' + +export class MaterialIdDto { + @ApiProperty({ title: 'ID', required: true }) + @IsString({ message: 'ID' }) + @Expose() + readonly id: string +} + +export const MaterialMediaSchema = z.object({ + url: z.string(), + type: z.nativeEnum(MediaType).describe('资源类型'), + content: z.string().optional().describe('文本内容'), + mediaId: z.string().optional().describe('资源ID'), +}) + +export const CreateMaterialSchema = z.object({ + groupId: z.string().describe('分组ID'), + coverUrl: z.string().optional().describe('封面图'), + mediaList: z.array(MaterialMediaSchema).describe('资源列表'), + title: z.string().describe('标题'), + desc: z.string().optional().describe('描述'), + option: z.any().optional().describe('其他属性'), + autoDeleteMedia: z.boolean().optional().describe('自动删除素材'), +}) +export class CreateMaterialDto extends createZodDto(CreateMaterialSchema) {} + +export const createMaterialTaskSchema = z.object({ + groupId: z.string().describe('分组ID'), + num: z.number().describe('生成数量'), + aiModelTag: z.string().describe('AI模型tag'), + prompt: z.string().describe('提示词'), + title: z.string().optional().describe('参考标题'), + desc: z.string().optional().describe('参考描述'), + mediaGroups: z.array(z.string()).min(1).max(5).describe('媒体组ID列表'), + coverGroup: z.string().describe('参考描述'), + option: z.any().optional().describe('高级设置'), + textMax: z.number().optional().describe('最大文字数量'), + language: z.enum(['中文', '英文']).optional().describe('语言'), + // type: z.enum([MaterialType.VIDEO, MaterialType.ARTICLE]).describe('草稿类型'), + autoDeleteMedia: z.boolean().optional().describe('自动删除素材'), +}) +export class CreateMaterialTaskDto extends createZodDto(createMaterialTaskSchema) {} + +export const UpdateMaterialSchema = z.object({ + coverUrl: z.string().optional().describe('封面图'), + mediaList: z.array(MaterialMediaSchema).describe('资源列表'), + title: z.string().describe('标题'), + desc: z.string().optional().describe('描述'), + option: z.any().optional().describe('其他属性'), + autoDeleteMedia: z.boolean().optional().describe('自动删除素材'), +}) +export class UpdateMaterialDto extends createZodDto(UpdateMaterialSchema) {} + +export const MaterialFilterSchema = z.object({ + title: z.string({ message: '标题' }).optional(), + groupId: z.string({ message: '组ID' }).optional(), + status: z.enum(MaterialStatus, { message: '草稿状态' }).optional(), + type: z.enum(MaterialType, { message: '草稿类型' }).optional(), + useCount: z.number().optional().describe('最小使用次数'), +}) + +export class MaterialFilterDto extends createZodDto(MaterialFilterSchema) {} + +const MaterialListSchema = z.object({ + filter: MaterialFilterSchema, + page: TableDto.schema, +}) + +export class MaterialListDto extends createZodDto(MaterialListSchema) {} + +const MediaIdsSchema = z.object({ + ids: z.array(z.string()).min(1).describe('ID列表'), +}) +export class MaterialIdsDto extends createZodDto(MediaIdsSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/materialGroup.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/materialGroup.dto.ts new file mode 100644 index 000000000..48264deef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/materialGroup.dto.ts @@ -0,0 +1,39 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: materialGroup MaterialGroup + */ +import { createZodDto, TableDtoSchema } from '@yikart/common' +import { MaterialType } from '@yikart/mongodb' +import { z } from 'zod' + +export const MaterialGroupIdSchema = z.object({ + id: z.string().describe('ID'), +}) +export class MaterialGroupIdDto extends createZodDto(MaterialGroupIdSchema) {} + +export const CreateMaterialGroupSchema = z.object({ + type: z.enum(MaterialType).describe('素材类型'), + name: z.string().describe('标题'), + desc: z.string().optional().describe('描述'), +}) +export class CreateMaterialGroupDto extends createZodDto(CreateMaterialGroupSchema) {} + +export const UpdateMaterialGroupSchema = z.object({ + name: z.string().describe('标题'), + desc: z.string().optional().describe('描述'), +}) +export class UpdateMaterialGroupDto extends createZodDto(UpdateMaterialGroupSchema) {} + +export const MaterialGroupFilterSchema = z.object({ + title: z.string().optional().describe('标题'), +}) +export class MaterialGroupFilterDto extends createZodDto(MaterialGroupFilterSchema) {} + +export const MaterialGroupListSchema = z.object({ + filter: MaterialGroupFilterSchema.optional().describe('过滤条件'), + page: TableDtoSchema.describe('分页信息'), +}) +export class MaterialGroupListDto extends createZodDto(MaterialGroupListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/media.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/media.dto.ts new file mode 100644 index 000000000..59b6e0dfe --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/media.dto.ts @@ -0,0 +1,50 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: Media media + */ +import { createZodDto, TableDtoSchema } from '@yikart/common' +import { MediaType } from '@yikart/mongodb' +import { z } from 'zod' +import { fileUtile } from '../../util/file.util' + +export const MediaIdSchema = z.object({ + id: z.string().describe('ID'), +}) +export class MediaIdDto extends createZodDto(MediaIdSchema) {} + +export const CreateMediaSchema = z.object({ + groupId: z.string().describe('组ID'), + materialId: z.string().optional().describe('素材ID'), + type: z.enum(MediaType).describe('类型'), + url: fileUtile.zodTrimHost().describe('文件链接'), + thumbUrl: fileUtile.zodTrimHost().optional().describe('缩略图'), + title: z.string().describe('标题'), + desc: z.string().describe('描述'), +}) +export class CreateMediaDto extends createZodDto(CreateMediaSchema) {} + +export const MediaFilterSchema = z.object({ + groupId: z.string().optional().describe('组ID'), + type: z.enum(MediaType).optional().describe('类型'), + useCount: z.number().optional().describe('use conut (min)'), +}) +export class MediaFilterDto extends createZodDto(MediaFilterSchema, 'MediaFilterDto') {} + +export const MediaListSchema = z.object({ + page: TableDtoSchema, + filter: MediaFilterSchema, +}) +export class MediaListDto extends createZodDto(MediaListSchema) {} + +const addUseCountOfListSchema = z.object({ + ids: z.array(z.string()).min(1).describe('ID列表'), +}) +export class AddUseCountOfListDto extends createZodDto(addUseCountOfListSchema) {} + +const MediaIdsSchema = z.object({ + ids: z.array(z.string()).min(1).describe('ID列表'), +}) +export class MediaIdsDto extends createZodDto(MediaIdsSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/mediaGroup.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/mediaGroup.dto.ts new file mode 100644 index 000000000..ef5873c22 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/dto/mediaGroup.dto.ts @@ -0,0 +1,44 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-05-13 16:00:00 + * @LastEditors: nevin + * @Description: mediaGroup MediaGroup + */ +import { createZodDto, TableDtoSchema } from '@yikart/common' +import { MediaType } from '@yikart/mongodb' +import { z } from 'zod' + +const MediaGroupIdSchema = z.object({ + id: z.string().describe('ID'), +}) + +export class MediaGroupIdDto extends createZodDto(MediaGroupIdSchema) {} + +const CreateMediaGroupSchema = z.object({ + type: z.enum(MediaType).describe('类型'), + title: z.string().describe('标题'), + desc: z.string().describe('描述'), +}) + +export class CreateMediaGroupDto extends createZodDto(CreateMediaGroupSchema) {} + +export const UpdateMediaSchema = z.object({ + title: z.string().optional().describe('标题'), + desc: z.string().optional().describe('描述'), +}) +export class UpdateMediaGroupDto extends createZodDto(UpdateMediaSchema) {} + +const MediaGroupFilterSchema = z.object({ + title: z.string().optional().describe('标题'), + type: z.enum(MediaType).optional().describe('类型'), +}) + +export class MediaGroupFilterDto extends createZodDto(MediaGroupFilterSchema) {} + +const MediaGroupListSchema = z.object({ + filter: MediaGroupFilterSchema.optional().describe('过滤条件'), + page: TableDtoSchema.describe('分页信息'), +}) + +export class MediaGroupListDto extends createZodDto(MediaGroupListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material-generate.consumer.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material-generate.consumer.ts new file mode 100644 index 000000000..f0a4ad27a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material-generate.consumer.ts @@ -0,0 +1,92 @@ +/* + * @Author: nevin + * @Date: 2024-07-03 15:16:12 + * @LastEditTime: 2025-02-10 17:18:50 + * @LastEditors: nevin + * @Description: 素材生成队列 + */ +import { + OnWorkerEvent, + Processor, + WorkerHost, +} from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { QueueName, QueueService } from '@yikart/aitoearn-queue' +import { MaterialTaskStatus } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import { Job } from 'bullmq' +import { MaterialService } from './material.service' +import { MaterialGroupService } from './materialGroup.service' +import { MaterialTaskService } from './materialTask.service' + +@Processor(QueueName.MaterialGenerate, { + concurrency: 3, + stalledInterval: 15000, + maxStalledCount: 1, +}) +export class MaterialGenerateConsumer extends WorkerHost { + logger = new Logger(MaterialGenerateConsumer.name) + constructor( + readonly redisService: RedisService, + readonly materialService: MaterialService, + readonly materialTaskService: MaterialTaskService, + readonly materialGroupService: MaterialGroupService, + private readonly queueService: QueueService, + ) { + super() + } + + async process( + job: Job<{ + taskId: string + }>, + ): Promise { + const taskInfo = await this.materialTaskService.getInfo(job.data.taskId) + this.logger.log({ + data: taskInfo, + message: '任务开始执行', + path: 'process --------- 0', + }) + if ( + !taskInfo + || [MaterialTaskStatus.FAIL, MaterialTaskStatus.SUCCESS].includes( + taskInfo.status, + ) + || taskInfo.reNum < 1 + ) { + this.logger.log({ + data: 0, + message: '任务退出执行', + path: 'process --------- 1', + }) + void job.isCompleted() + return + } + + const { status, message } + = await this.materialTaskService.doCreateTask(taskInfo) + this.logger.log({ + data: { status, message }, + message: '任务执行结果', + path: 'process --------- 2', + }) + if (status === -1) { + this.logger.log({ msg: message }) + void job.isCompleted() + return + } + + await this.queueService.addMaterialGenerateJob({ + taskId: job.data.taskId, + }, { + removeOnFail: true, + }) + } + + @OnWorkerEvent('completed') + // onCompleted(e: any) { + onCompleted() { + // do some stuff + this.logger.log('--- bull_material_generate --- completed') + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.controller.ts new file mode 100644 index 000000000..fe60a9c00 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.controller.ts @@ -0,0 +1,194 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 草稿 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { ApiDoc, AppException, ResponseCode, TableDto, UserType } from '@yikart/common' +import { MaterialType, MediaType } from '@yikart/mongodb' +import { + CreateMaterialDto, + CreateMaterialTaskDto, + MaterialFilterDto, + MaterialFilterSchema, + MaterialIdsDto, + UpdateMaterialDto, +} from './dto/material.dto' +import { MaterialService } from './material.service' +import { MaterialGroupService } from './materialGroup.service' +import { MaterialTaskService } from './materialTask.service' +import { MediaGroupService } from './mediaGroup.service' + +export const MediaMaterialTypeMap = new Map([ + [MediaType.VIDEO, MaterialType.VIDEO], + [MediaType.IMG, MaterialType.ARTICLE], +]) + +@ApiTags('草稿') +@Controller('material') +export class MaterialController { + constructor( + private readonly materialService: MaterialService, + private readonly materialGroupService: MaterialGroupService, + private readonly materialTaskService: MaterialTaskService, + private readonly mediaGroupService: MediaGroupService, + ) { } + + @ApiOperation({ + summary: '创建草稿', + description: '创建草稿', + }) + @Post() + async create( + @GetToken() token: TokenInfo, + @Body() body: CreateMaterialDto, + ) { + const getInfo = await this.materialGroupService.getGroupInfo(body.groupId) + if (!getInfo) { + throw new AppException(1000, '素材组不存在') + } + const res = await this.materialService.create({ + ...body, + userId: token.id, + userType: UserType.User, + type: getInfo?.type, + }) + return res + } + + @ApiOperation({ + summary: '创建批量生成草稿任务', + description: '创建批量生成草稿任务', + }) + @Post('task/create') + async createTask( + @GetToken() token: TokenInfo, + @Body() body: CreateMaterialTaskDto, + ) { + // await this.userService.checkUserVipRights(token.id) + const mediaGroupInfo = await this.mediaGroupService.getInfo(body.mediaGroups[0]) + if (!mediaGroupInfo) { + throw new AppException(1000, '素材组不存在') + } + + const type = mediaGroupInfo?.type + const res = await this.materialTaskService.createTask({ + ...body, + type: MediaMaterialTypeMap.get(type)!, + }) + return res + } + + @ApiOperation({ + summary: '预览草稿生成任务', + description: '预览草稿生成任务', + }) + @Get('task/preview/:id') + async previewTask(@Param('id') id: string) { + const res = await this.materialTaskService.previewTask(id) + return res + } + + @ApiOperation({ + summary: '开始草稿生成任务', + description: '开始草稿生成任务', + }) + @Get('task/start/:id') + async startTask(@Param('id') id: string) { + const res = await this.materialTaskService.startTask(id) + return res + } + + @ApiOperation({ + summary: '批量删除草稿', + description: '根据筛选', + }) + @Delete('filter') + async delByFilter( + @GetToken() token: TokenInfo, + @Body() body: MaterialFilterDto, + ) { + const res = await this.materialService.delByFilter(token.id, body) + return res + } + + @ApiOperation({ + summary: '批量删除草稿', + description: '根据ID列表', + }) + @Delete('list') + async delByIds( + @GetToken() token: TokenInfo, + @Body() body: MaterialIdsDto, + ) { + const res = await this.materialService.delByIds(token.id, body.ids) + return res + } + + @ApiOperation({ + summary: '删除草稿', + description: '删除草稿', + }) + @Delete(':id') + async del( + @GetToken() token: TokenInfo, + @Param('id') id: string, + ) { + const material = await this.materialService.getInfo(id) + if (!material || material.userId !== token.id) { + throw new AppException(ResponseCode.MaterialNotFound, 'Material not found') + } + const res = await this.materialService.del(id) + return res + } + + @ApiOperation({ + summary: '更新草稿信息', + description: '更新草稿信息', + }) + @Put('info/:id') + async upMaterialInfo( + @GetToken() token: TokenInfo, + @Param('id') id: string, + @Body() body: UpdateMaterialDto, + ) { + const material = await this.materialService.getInfo(id) + if (!material || material.userId !== token.id) { + throw new AppException(ResponseCode.MaterialNotFound, 'Material not found') + } + const res = await this.materialService.updateInfo(id, body) + return res + } + + @ApiDoc({ + summary: '获取草稿列表', + description: '获取草稿列表', + query: MaterialFilterSchema, + }) + @Get('list/:pageNo/:pageSize') + async getList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: MaterialFilterDto, + ) { + const res = await this.materialService.getList( + param, + token.id, + query, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.service.ts new file mode 100644 index 000000000..84091417a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/material.service.ts @@ -0,0 +1,184 @@ +import { Injectable } from '@nestjs/common' +import { TableDto, UserType } from '@yikart/common' +import { Material, MaterialRepository, MaterialStatus, MaterialType } from '@yikart/mongodb' +import { NewMaterial, UpMaterial } from './common' +import { MediaService } from './media.service' + +@Injectable() +export class MaterialService { + constructor( + private readonly materialRepository: MaterialRepository, + private readonly mediaService: MediaService, + ) { } + + /** + * 创建 + * @param newData + * @returns + */ + async create(newData: NewMaterial) { + const res = await this.materialRepository.create(newData) + return res + } + + /** + * delete material + * @param id + * @returns + */ + async del(id: string) { + const material = await this.getInfo(id) + if (!material) + return true + const res = await this.materialRepository.delOne(id) + if (!res) + return false + // 删除媒体资源 + if (material.autoDeleteMedia) { + for (const item of material.mediaList) { + if (!item.mediaId) + continue + this.mediaService.del(item.mediaId) + } + } + return res + } + + /** + * 批量删除素材 + * @param ids + * @returns + */ + async delByIds(userId: string, ids: string[]): Promise { + const res = await this.materialRepository.delByIds(ids, { userId }) + return res + } + + /** + * delete material (TODO: 待优化) + * @param userId + * @param filter + * @returns + */ + async delByFilter( + userId: string, + inFilter: { + title?: string + groupId?: string + status?: MaterialStatus + useCount?: number + type?: MaterialType + }, + ) { + const { groupId, type, useCount, title, status } = inFilter + const filter = { + userId, + userType: UserType.User, + ...(groupId && { groupId }), + ...(type && { type }), + ...(useCount !== undefined && { useCount: { $gte: useCount } }), + ...(title && { title: { $regex: title, $options: 'i' } }), + ...(status !== undefined && { status }), + } + const res = await this.materialRepository.delByFilter(filter) + return res + } + + /** + * 更新素材信息 + * @param id + * @param data + * @returns + */ + async updateInfo(id: string, data: UpMaterial): Promise { + const res = await this.materialRepository.updateInfo(id, data) + return res + } + + /** + * 获取素材信息 + * @param id + * @returns + */ + async getInfo(id: string): Promise { + const res = await this.materialRepository.getInfo(id) + return res + } + + /** + * 获取组内最优素材 + * @param groupId + * @returns + */ + async optimalInGroup(groupId: string): Promise { + const res = await this.materialRepository.optimalInGroup(groupId) + return res + } + + /** + * 获取草稿列表 + * @param page + * @param userId + * @param filter + * @returns + */ + async getList( + page: TableDto, + userId: string, + filter: { + userId?: string + userType?: UserType + title?: string + groupId?: string + status?: MaterialStatus + ids?: string[] + useCount?: number + }, + ) { + const res = await this.materialRepository.getList({ + userId, + ...filter, + }, page) + return res + } + + /** + * 获取素材列表 + * @param materialIds + * @returns + */ + async optimalByIds(materialIds: string[]) { + const res = await this.materialRepository.optimalByIds(materialIds) + return res + } + + /** + * 草稿素材列表 + * @param ids + * @returns + */ + async getListByIds(ids: string[]) { + const res = await this.materialRepository.listByIds(ids) + return res + } + + /** + * 开始生成任务 + * @param id + * @returns + */ + async updateStatus(id: string, status: MaterialStatus, message: string) { + const res = await this.materialRepository.updateStatus(id, status, message) + return res + } + + /** + * 使用计数增加 + * @param id + * @returns + */ + async addUseCount(id: string) { + const res = await this.materialRepository.addUseCount(id) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.controller.ts new file mode 100644 index 000000000..75fabfda7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.controller.ts @@ -0,0 +1,98 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 草稿组 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode, TableDto } from '@yikart/common' +import { CreateMaterialGroupDto, MaterialGroupFilterDto, UpdateMaterialGroupDto } from './dto/materialGroup.dto' +import { MaterialGroupService } from './materialGroup.service' + +@ApiTags('草稿') +@Controller('material/group') +export class MaterialGroupController { + constructor( + private readonly materialGroupService: MaterialGroupService, + ) { } + + @ApiOperation({ + description: '创建草稿组', + summary: '创建草稿组', + }) + @Post() + async createGroup( + @GetToken() token: TokenInfo, + @Body() body: CreateMaterialGroupDto, + ) { + const res = await this.materialGroupService.createGroup({ + ...body, + userId: token.id, + }) + return res + } + + @ApiOperation({ + description: '删除素材组', + summary: '删除素材组', + }) + @Delete(':id') + async delGroup( + @GetToken() token: TokenInfo, + @Param('id') id: string, + ) { + const materialGroup = await this.materialGroupService.getGroupInfo(id) + if (!materialGroup || materialGroup.userId !== token.id) { + throw new AppException(ResponseCode.MaterialGroupNotFound, 'Material Group not found') + } + const res = await this.materialGroupService.delGroup(id) + return res + } + + @ApiOperation({ + description: '更新素材组信息', + summary: '更新素材组信息', + }) + @Post('info/:id') + async updateGroupInfo( + @GetToken() token: TokenInfo, + @Param('id') id: string, + @Body() body: UpdateMaterialGroupDto, + ) { + const materialGroup = await this.materialGroupService.getGroupInfo(id) + if (!materialGroup || materialGroup.userId !== token.id) { + throw new AppException(ResponseCode.MaterialGroupNotFound, 'Material Group not found') + } + const res = await this.materialGroupService.updateGroupInfo(id, body) + return res + } + + @ApiOperation({ + description: '获取素材组列表', + summary: '获取素材组列表', + }) + @Get('list/:pageNo/:pageSize') + async getGroupList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: MaterialGroupFilterDto, + ) { + const { list, total } = await this.materialGroupService.getGroupList(param, { + userId: token.id, + ...query, + }) + + return { list, total } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.service.ts new file mode 100644 index 000000000..811aab120 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialGroup.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { MaterialGroupRepository } from '@yikart/mongodb' +import { NewMaterialGroup, UpdateMaterialGroup } from './common' + +@Injectable() +export class MaterialGroupService { + constructor( + private readonly materialGroupRepository: MaterialGroupRepository, + ) { } + + async createGroup(newData: NewMaterialGroup) { + const res = await this.materialGroupRepository.create(newData) + return res + } + + async delGroup(id: string): Promise { + const res = await this.materialGroupRepository.delete(id) + return res + } + + async updateGroupInfo(id: string, newData: UpdateMaterialGroup) { + const res = await this.materialGroupRepository.update(id, newData) + return res + } + + async getGroupInfo(id: string) { + const res = await this.materialGroupRepository.getById(id) + return res + } + + async getGroupList( + page: TableDto, + filter: { + userId: string + title?: string + }, + ) { + const res = await this.materialGroupRepository.getList(filter, page) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialTask.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialTask.service.ts new file mode 100644 index 000000000..3e25d4465 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/materialTask.service.ts @@ -0,0 +1,562 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: MaterialTask materialTask + */ +import { Injectable, Logger } from '@nestjs/common' +import { QueueService } from '@yikart/aitoearn-queue' +import { buildUrl } from '@yikart/aws-s3' +import { AppException, UserType } from '@yikart/common' +import { MaterialStatus, MaterialTaskRepository, MaterialType } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import { AiService } from '../ai/ai.service' +import { config } from '../config' +import { MaterialMedia, MaterialTask, MediaType, MediaUrlInfo, NewMaterial, NewMaterialTask } from './common' +import { CreateMaterialTaskDto } from './dto/material.dto' +import { MaterialService } from './material.service' +import { MaterialGroupService } from './materialGroup.service' +import { MediaService } from './media.service' +import { MediaGroupService } from './mediaGroup.service' + +export const MaterialMediaTypeMap = new Map([ + [MaterialType.VIDEO, MediaType.VIDEO], + [MaterialType.ARTICLE, MediaType.IMG], +]) +@Injectable() +export class MaterialTaskService { + logger = new Logger(MaterialTaskService.name) + + constructor( + readonly redisService: RedisService, + private readonly materialTaskRepository: MaterialTaskRepository, + readonly aiService: AiService, + readonly mediaService: MediaService, + readonly materialService: MaterialService, + private readonly materialGroupService: MaterialGroupService, + private readonly mediaGroupService: MediaGroupService, + private readonly queueService: QueueService, + ) { } + + /** + * 批量生成任务 + * @param newData + * @returns + */ + async createTask(data: NewMaterialTask) { + const { coverGroup, mediaGroups, type } = data + // 验证组不能为空组 + if (coverGroup) { + const coverGroupIsEmpty = await this.mediaService.checkIsEmptyGroup(coverGroup) + if (coverGroupIsEmpty) + throw new AppException(1000, '封面组不能为空组') + } + if (mediaGroups && mediaGroups.length > 0) { + for (const mediaGroupId of mediaGroups) { + const mediaGroup = await this.mediaGroupService.getInfo(mediaGroupId) + if (!mediaGroup) + throw new AppException(1000, '媒体组不存在') + + const needType = MaterialMediaTypeMap.get(type) + if (!needType) + throw new AppException(1000, '暂不支持该素材类型') + if (mediaGroup.type !== needType) + throw new AppException(1000, '媒体组类型错误') + + const mediaGroupIsEmpty = await this.mediaService.checkIsEmptyGroup(mediaGroupId) + + if (mediaGroupIsEmpty) + throw new AppException(1000, '内容组不能为空组') + } + } + const res = await this.addCreateMaterialTask(data) + return res + } + + /** + * 生成任务结果预览 + * @param newData + * @returns + */ + async previewTask(taskId: string) { + const taskInfo = await this.getInfo(taskId) + if (!taskInfo) + throw new AppException(1, '任务信息不存在') + + const res = await this.doCreateTask(taskInfo, true) + return res + } + + /** + * 开始生成任务 + * @param id + * @returns + */ + async startTask(id: string) { + const taskInfo = await this.getInfo(id) + if (!taskInfo) + throw new AppException(1, '任务信息不存在') + // 开始任务 + await this.queueService.addMaterialGenerateJob({ + taskId: taskInfo.id, + }) + return taskInfo._id + } + + /** + * 生成媒体的二维数组 + * @param mediaGroups 媒体组ID数组 + * @returns + */ + async generateMediaUrlMap(mediaGroups: string[]) { + const mediaUrlMap: MediaUrlInfo[][] = [] + for (const materialGroup of mediaGroups) { + const mediaList = await this.mediaService.getListByGroup(materialGroup) + const mediaUrlList: MediaUrlInfo[] = [] + for (const media of mediaList) { + mediaUrlList.push({ + mediaId: media.id, + url: media.url, + num: 0, + type: media.type, + }) + } + mediaUrlMap.push(mediaUrlList) + } + + return mediaUrlMap + } + + /** + * 创建任务信息 + * @param inData + * @returns + */ + async addCreateMaterialTask(inData: CreateMaterialTaskDto) { + const groupInfo = await this.materialGroupService.getGroupInfo(inData.groupId) + if (!groupInfo) + throw new AppException(1000, '组信息不存在') + + const newData: Partial = { + userId: groupInfo.userId, + userType: groupInfo.userType, + groupId: inData.groupId, + type: groupInfo.type, + aiModelTag: inData.aiModelTag, + prompt: inData.prompt, + coverGroup: inData.coverGroup, + mediaGroups: inData.mediaGroups, + option: inData.option, + title: inData.title, + desc: inData.desc, + reNum: inData.num, + textMax: inData.textMax, + language: inData.language, + autoDeleteMedia: inData.autoDeleteMedia, + } + + // 媒体组 + newData.mediaUrlMap = await this.generateMediaUrlMap(inData.mediaGroups) + + // 封面 + const cover = await this.mediaService.getListByGroup(inData.coverGroup) + newData.coverUrlList = cover.map(item => ({ + mediaId: item.id, + url: item.url, + num: 0, + type: item.type, + })) + + const res = await this.materialTaskRepository.create(newData) + return res + } + + /** + * 生成素材文案内容 + * @param user + * @param model + * @param type + * @param imgUrl + * @param option + * @returns + */ + async generateMediaContent( + user: { userId: string, userType: UserType }, + model: string, + type: MediaType, + fileUrl: string, + prompt: string, + option: { + title?: string + desc?: string + max?: number + language?: string + }, + ) { + if (type === MediaType.IMG) { + const res = await this.aiService.imgContentByAi( + user, + model, + fileUrl, + prompt, + option, + ) + return res + } + + if (type === MediaType.VIDEO) { + const res = await this.aiService.videoContentByAi( + user, + model, + fileUrl, + prompt, + option, + ) + return res + } + + return '' + } + + /** + * 生成媒体文案内容 + * @param user + * @param model + * @param prompt + * @param option + * @param coverUrl + * @returns + */ + async generateMaterialContent( + user: { userId: string, userType: UserType }, + model: string, + prompt: string, + option: { + coverUrl?: string + title?: string + desc?: string + max?: number + language?: string + }, + coverUrl?: string, + ) { + const res = { + title: option.title, + content: option.desc, + } + + const content = await this.aiService.getContentByAi(user, model, prompt, { + ...option, + coverUrl, + }) + if (!content) + return res + res.content = content + + const title = await this.aiService.getTitleByAi(user, model, content, { + ...option, + }) + if (!title) + return res + res.title = title + return res + } + + /** + * 获取内容片段 + */ + async getContentItems( + taskInfo: { + mediaUrlMap: MediaUrlInfo[][] + title?: string + desc?: string + textMax?: number + language?: string + userId: string + userType: UserType + aiModelTag: string + }, + ) { + const res: { + status: -1 | 0 | 1 + message: string + data: { + mediaList: MaterialMedia[] + content: string + } + } = { + status: 0, + message: '', + data: { + mediaList: [], + content: '', + }, + } + + for (const mediaUrlList of taskInfo.mediaUrlMap) { + // 1. 根据num的值,重新从小到大排列mediaUrlLiskt + mediaUrlList.sort((a, b) => a.num - b.num) + + // 取第一张图,进行媒体内容创建 + const theOne = mediaUrlList[0] + if (!theOne) + continue + + const content = await this.generateMediaContent( + { userId: taskInfo.userId, userType: taskInfo.userType }, + taskInfo.aiModelTag, + theOne.type, + await buildUrl(config.awsS3.endpoint, theOne.url), + taskInfo.aiModelTag, + { + title: taskInfo.title, + desc: taskInfo.desc, + max: taskInfo.textMax, + language: taskInfo.language, + }, + ) + if (!content) { + res.status = -1 + res.message = `${theOne.url}生成内容失败` + return res + } + + res.data.mediaList.push({ + url: theOne.url, + type: theOne.type, + content, + }) + res.data.content += content + + // 增加媒体的计数 + theOne.num++ + } + + return res + } + + async create(newData: Partial) { + return await this.materialTaskRepository.create(newData) + } + + // 添加使用次数 + private addUseCount(taskInfo: MaterialTask) { + try { + const { mediaUrlMap, coverUrlList } = taskInfo + mediaUrlMap.forEach((mediaUrlList) => { + mediaUrlList.forEach((mediaUrlInfo) => { + this.mediaService.addUseCount(mediaUrlInfo.mediaId) + }) + }) + + coverUrlList.forEach((coverUrlInfo) => { + this.mediaService.addUseCount(coverUrlInfo.mediaId) + }) + } + catch (error) { + this.logger.error(error) + } + } + + /** + * 进行生成任务 + * @param taskInfo + * @returns + */ + async doCreateTask(taskInfo: MaterialTask, preview = false): Promise<{ + status: -1 | 0 | 1 + message: string + data?: NewMaterial + }> { + const res: { + status: -1 | 0 | 1 + message: string + data?: NewMaterial + } = { + status: 0, + message: '', + } + + // 创建一个20分钟的超时Promise + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error('任务执行超时(超过10分钟)')) + }, 10 * 60 * 1000) // 20分钟超时 + }) + + const mainTaskPromise = (async () => { + try { + const groupInfo = await this.materialGroupService.getGroupInfo(taskInfo.groupId) + if (!groupInfo) { + res.status = -1 + res.message = '草稿组不存在' + return res + } + + // 创建草稿初始数据 预览不入库 + const newMaterialData: NewMaterial = { + userId: groupInfo.userId, + userType: groupInfo.userType, + groupId: groupInfo.id, + taskId: taskInfo.id, + type: groupInfo.type, + mediaList: [], + title: '', + desc: '', + option: taskInfo.option, + autoDeleteMedia: false, + } + const newMaterial = preview + ? newMaterialData + : await this.materialService.create(newMaterialData) + if (!newMaterial) { + res.status = -1 + res.message = '创建草稿初始数据失败' + return res + } + + // 生成内容和项目列表 + const { + status, + message, + data: { content: dbDesc, mediaList }, + } = await this.getContentItems(taskInfo) + if (status === -1) { + if (!preview) { + void this.materialService.updateStatus( + newMaterial.id, + MaterialStatus.FAIL, + message, + ) + } + + res.status = -1 + res.message = '生成内容数据失败' + return res + } + + // 封面 + const { coverUrlList } = taskInfo + let theOneCover: MediaUrlInfo = { + mediaId: '', + url: '', + num: 0, + type: MediaType.IMG, + } + if (coverUrlList && coverUrlList.length > 0) { + coverUrlList.sort((a, b) => a.num - b.num) + theOneCover = coverUrlList[0] + theOneCover.num++ + } + + // 传入封面,生成内容 + const contentRes = await this.generateMaterialContent( + { userId: taskInfo.userId, userType: taskInfo.userType }, + taskInfo.aiModelTag, + taskInfo.prompt, + { + coverUrl: theOneCover.url, + title: taskInfo.title, + desc: dbDesc, + max: taskInfo.textMax, + language: taskInfo.language, + }, + theOneCover.url + ? await buildUrl(config.awsS3.endpoint, theOneCover.url) + : undefined, + ) + + // 更新草稿信息信息 + const updateData = { + userId: groupInfo.userId, + groupId: groupInfo.id, + type: groupInfo.type, + mediaList, + title: contentRes.title || '', + desc: contentRes.content, + option: taskInfo.option, + coverUrl: theOneCover.url || undefined, + status: MaterialStatus.SUCCESS, + message: '创建成功', + } + if (preview) { + res.status = 1 + res.message = '预览数据成功' + res.data = { + id: 'private', + userType: UserType.User, + ...updateData, + autoDeleteMedia: false, + } + return res + } + + // 更新数据库 + const upDbRes = await this.materialService.updateInfo( + newMaterial.id, + updateData, + ) + if (!upDbRes) { + res.status = -1 + res.message = '更新内容失败' + return res + } + + // 更新任务 + // 剩余次数 -1 + taskInfo.reNum = taskInfo.reNum - 1 + + // 素材增加使用次数 + this.addUseCount(taskInfo) + + // 更新任务信息 + const upRes = await this.update(taskInfo.id, taskInfo) + if (!upRes) { + res.status = -1 + res.message = '更新内容失败' + return res + } + + res.status = 1 + res.message = '执行成功' + return res + } + catch (error: any) { + res.status = -1 + res.message = error + return res + } + })() + + // 使用Promise.race来实现超时控制 + try { + const result = await Promise.race([mainTaskPromise, timeoutPromise]) + return result + } + catch (error: any) { + // 超时或发生错误时更新任务状态 + if (!preview) { + // 如果不是预览模式,尝试更新任务状态为失败 + void this.materialService.updateStatus( + taskInfo.id, + MaterialStatus.FAIL, + '任务执行超时(超过20分钟)', + ) + } + + res.status = -1 + res.message = error.message || '任务执行超时' + return res + } + } + + async update(id: string, newData: Partial): Promise { + const res = await this.materialTaskRepository.update(id, newData) + return res + } + + async getInfo(id: string) { + return await this.materialTaskRepository.getInfo(id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.controller.ts new file mode 100644 index 000000000..89a760c13 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.controller.ts @@ -0,0 +1,117 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 媒体资源 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { ApiDoc, AppException, ResponseCode, TableDto } from '@yikart/common' +import { Media } from '@yikart/mongodb' +import { fileUtile } from '../util/file.util' +import { AddUseCountOfListDto, CreateMediaDto, MediaFilterDto, MediaFilterSchema, MediaIdsDto } from './dto/media.dto' +import { MediaService } from './media.service' + +@ApiTags('媒体资源') +@Controller('media') +export class MediaController { + constructor(private readonly mediaService: MediaService) { } + + private processMediaFiles(mediaList: Media[]) { + mediaList.forEach((media) => { + media.url = fileUtile.buildUrl(media.url) + media.thumbUrl = fileUtile.buildUrl(media.thumbUrl) + }) + } + + @ApiOperation({ + description: '创建媒体资源', + summary: '创建媒体资源', + }) + @Post() + async create( + @GetToken() token: TokenInfo, + @Body() body: CreateMediaDto, + ) { + const res = await this.mediaService.create(token.id, body) + this.processMediaFiles([res]) + return res + } + + @ApiOperation({ + description: '根据ID列表', + summary: '批量删除媒体资源', + }) + @Delete('ids') + async delByIds(@GetToken() token: TokenInfo, @Body() body: MediaIdsDto) { + const res = await this.mediaService.delByIds(token.id, body.ids) + return res + } + + @ApiOperation({ + description: 'Filter', + summary: 'Delete By Filter', + }) + @Delete('filter') + async delByFilter(@GetToken() token: TokenInfo, @Body() body: MediaFilterDto) { + const res = await this.mediaService.delByFilter(token.id, body) + return res + } + + @ApiOperation({ + description: '删除媒体资源', + summary: '删除媒体资源', + }) + @Delete(':id') + async del(@GetToken() token: TokenInfo, @Param('id') id: string) { + const media = await this.mediaService.getInfo(id) + if (!media || media.userId !== token.id) { + throw new AppException(ResponseCode.MediaNotFound, 'Media Group not found') + } + const res = await this.mediaService.del(id) + return res + } + + @Get('list/:pageNo/:pageSize') + @ApiDoc({ + summary: '获取媒体列表', + description: '获取媒体列表', + query: MediaFilterSchema, + }) + async getList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: MediaFilterDto, + ) { + const res = await this.mediaService.getList(param, { + userId: token.id, + ...query, + }) + this.processMediaFiles(res.list) + return res + } + + @ApiOperation({ + description: '批量更新素材的使用次数', + summary: '批量更新素材的使用次数', + }) + @Put('addUseCountOfList') + async addUseCountOfList( + @GetToken() token: TokenInfo, + @Body() body: AddUseCountOfListDto, + ) { + const res = await this.mediaService.addUseCountOfList(token.id, body.ids) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.service.ts new file mode 100644 index 000000000..1c14912ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/media.service.ts @@ -0,0 +1,218 @@ +import { basename, join } from 'node:path' +import { Injectable } from '@nestjs/common' +import { S3Service } from '@yikart/aws-s3' +import { TableDto, UserType } from '@yikart/common' +import { Media, MediaRepository, MediaType } from '@yikart/mongodb' +import { config } from '../config' +import { StorageService } from '../user/storage.service' +import { CreateMediaDto } from './dto/media.dto' + +@Injectable() +export class MediaService { + constructor( + private readonly s3Service: S3Service, + private readonly mediaRepository: MediaRepository, + private readonly storageService: StorageService, + ) { } + + async create(userId: string, newData: CreateMediaDto) { + let path = newData.url + try { + const url = new URL(newData.url) + if (url.origin === config.awsS3.endpoint) { + path = url.pathname.substring(1) + } + else { + path = join(userId, `${Date.now().toString(36)}-${basename(url.pathname)}`) + await this.s3Service.putObjectFromUrl(url.href, path) + } + } + catch { } + + const metadata = await this.s3Service.headObject(path) + + await this.storageService.addUsedStorage({ + userId, + amount: metadata.ContentLength!, + }) + + const res = await this.mediaRepository.create({ + ...newData, + userId, + userType: UserType.User, + url: path, + metadata: { + size: metadata.ContentLength!, + mimeType: metadata.ContentType!, + }, + }) + return res + } + + /** + * delete media + * @param id + * @returns + */ + async del(id: string) { + const media = await this.mediaRepository.getInfo(id) + if (media?.userType === UserType.User && media?.url && media.metadata?.size) { + await this.storageService.deductUsedStorage({ + userId: media.userId, + amount: media.metadata.size, + }) + } + + const res = await this.mediaRepository.delOne(id) + return res + } + + /** + * delete media + * @param ids + * @returns + */ + async delByIds(userId: string, ids: string[]) { + const mediaList = await this.mediaRepository.getListByIds(ids) + for (const media of mediaList) { + if (media?.userType === UserType.User && media?.url && media.metadata?.size) { + this.storageService.deductUsedStorage({ + userId: media.userId, + amount: media.metadata.size, + }) + } + } + + const res = await this.mediaRepository.delByIds(ids, { + userType: UserType.User, + userId, + }) + return res + } + + /** + * delete media (TODO: 待优化) + * @param userId + * @param inFilter + * @returns + */ + async delByFilter( + userId: string, + inFilter: { + groupId?: string + type?: MediaType + useCount?: number + }, + ) { + const { groupId, type, useCount } = inFilter + const filter = { + userId, + userType: UserType.User, + ...(groupId && { groupId }), + ...(type && { type }), + ...(useCount !== undefined && { useCount: { $gte: useCount } }), + } + const mediaList = await this.mediaRepository.getListByFilter(filter) + for (const media of mediaList) { + if (media?.userType === UserType.User && media?.url && media.metadata?.size) { + this.storageService.deductUsedStorage({ + userId: media.userId, + amount: media.metadata.size, + }) + } + } + + const res = await this.mediaRepository.delByFilter(filter) + return res + } + + /** + * 获取素材信息 + * @param id + * @returns + */ + async getInfo(id: string): Promise { + const res = await this.mediaRepository.getInfo(id) + return res + } + + /** + * 获取素材列表 + * @param page + * @param filter + * @param filter.userId + * @param filter.groupId + * @param filter.type + * @returns + */ + async getList( + page: TableDto, + filter: { + userId: string + groupId?: string + type?: MediaType + userType?: UserType + useCount?: number + }, + ) { + const res = await this.mediaRepository.getList(filter, page) + return res + } + + async getListByGroup(groupId: string) { + const res = await this.mediaRepository.getListByGroup(groupId) + return res + } + + async addUseCountOfList(userId: string, ids: string[]): Promise { + const res = await this.mediaRepository.addUseCountOfList(ids, { + userId, + }) + return res + } + + /** + * delete media + * @param id + * @returns + */ + async updateInfo(id: string, newData: Partial) { + const oldMedia = await this.mediaRepository.getInfo(id) + if (oldMedia?.url !== newData.url) { + const metadata = await this.s3Service.headObject(newData.url!) + newData.metadata = { + size: metadata.ContentLength!, + mimeType: metadata.ContentType!, + } + + if (newData.userType === UserType.User) { + await this.storageService.deductUsedStorage({ + userId: newData.userId!, + amount: oldMedia?.metadata?.size || 0, + }) + await this.storageService.addUsedStorage({ + userId: newData.userId!, + amount: metadata.ContentLength!, + }) + } + } + + const res = await this.mediaRepository.updateInfo(id, newData) + return res + } + + /** + * 检查指定组是否为空(不包含任何媒体文件) + * @param groupId 组ID + * @returns 如果组为空返回true,否则返回false + */ + async checkIsEmptyGroup(groupId: string): Promise { + const exists = await this.mediaRepository.checkIsEmptyGroup(groupId) + return exists + } + + async addUseCount(id: string): Promise { + const res = await this.mediaRepository.addUseCount(id) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.controller.ts new file mode 100644 index 000000000..54d44599c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.controller.ts @@ -0,0 +1,129 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 媒体资源 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode, TableDto } from '@yikart/common' +import { MediaGroup } from '@yikart/mongodb' +import { fileUtile } from '../util/file.util' +import { + CreateMediaGroupDto, + MediaGroupFilterDto, + UpdateMediaGroupDto, +} from './dto/mediaGroup.dto' +import { MediaService } from './media.service' +import { MediaGroupService } from './mediaGroup.service' + +@ApiTags('媒体资源组') +@Controller('media/group') +export class MediaGroupController { + constructor( + private readonly mediaGroupService: MediaGroupService, + private readonly mediaService: MediaService, + ) { } + + @ApiOperation({ + description: '创建发媒体资源组', + summary: '创建发媒体资源组', + }) + @Post() + async createGroup( + @GetToken() token: TokenInfo, + @Body() body: CreateMediaGroupDto, + ) { + const res = await this.mediaGroupService.create(token.id, { + type: body.type, + title: body.title, + desc: body.desc, + }) + return res + } + + @ApiOperation({ + description: '删除媒体资源组', + summary: '删除媒体资源', + }) + @Delete(':id') + async delGroup(@GetToken() token: TokenInfo, @Param('id') id: string) { + const mediaGroup = await this.mediaGroupService.getInfo(id) + if (!mediaGroup || mediaGroup.userId !== token.id) { + throw new AppException(ResponseCode.MediaGroupNotFound, 'Media Group not found') + } + const res = await this.mediaGroupService.del(id) + return res + } + + @ApiOperation({ + description: '更新资源组信息', + summary: '更新资源组信息', + }) + @Post('info/:id') + async updateGroupInfo( + @GetToken() token: TokenInfo, + @Param('id') id: string, + @Body() body: UpdateMediaGroupDto, + ) { + const dataInfo = await this.mediaGroupService.getInfo(id) + if (!dataInfo || dataInfo.userId !== token.id) { + throw new AppException(10009, 'No permission to operate this resource group') + } + const res = await this.mediaGroupService.updateInfo(id, body) + return res + } + + /** + * 获取资源组的简略图列表 + * @param userId + * @param group + * @returns + */ + private async getMediaDesList(userId: string, group: MediaGroup) { + const res = await this.mediaService.getList( + { pageNo: 1, pageSize: 3 }, + { + userId, + groupId: (group as any)._id, + }, + ) + res.list.forEach((item) => { + item.url = fileUtile.buildUrl(item.url) + item.thumbUrl = fileUtile.buildUrl(item.thumbUrl) + }) + return { ...group, mediaList: res } + } + + @ApiOperation({ + description: '获取媒体组列表', + summary: '获取媒体组列表', + }) + @Get('list/:pageNo/:pageSize') + async getGroupList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: MediaGroupFilterDto, + ) { + const { list, total } = await this.mediaGroupService.getList(param, { + userId: token.id, + ...query, + }) + + const updatedList = await Promise.all( + list.map(item => this.getMediaDesList(token.id, item)), + ) + + return { list: updatedList, total } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.service.ts new file mode 100644 index 000000000..92ac3d085 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/content/mediaGroup.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { MediaGroupRepository, MediaType } from '@yikart/mongodb' + +@Injectable() +export class MediaGroupService { + constructor(private readonly mediaGroupRepository: MediaGroupRepository) { } + async create( + userId: string, + inData: { title: string, type: MediaType, desc?: string }, + ) { + const res = await this.mediaGroupRepository.create({ + userId, + ...inData, + }) + return res + } + + async del(id: string): Promise { + const res = await this.mediaGroupRepository.delete(id) + return !!res + } + + async updateInfo( + id: string, + newData: { title?: string, desc?: string }, + ) { + const res = await this.mediaGroupRepository.update(id, newData) + return res + } + + async getInfo(id: string) { + const res = await this.mediaGroupRepository.getInfo(id) + return res + } + + async getList( + page: TableDto, + filter: { + userId: string + title?: string + type?: MediaType + }, + ) { + const res = await this.mediaGroupRepository.getList(filter, page) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/dto/feedback.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/dto/feedback.dto.ts new file mode 100644 index 000000000..5bd8d160c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/dto/feedback.dto.ts @@ -0,0 +1,21 @@ +import { createZodDto } from '@yikart/common' +import { FeedbackType } from '@yikart/mongodb' +import { z } from 'zod' + +export const CreateFeedBackSchema = z.object({ + content: z.string().describe('内容'), + type: z.enum(FeedbackType).optional().describe('类型'), + tagList: z.array(z.string()).optional().describe('标识数组'), + fileUrlList: z.array(z.string()).optional().describe('文件链接数组'), +}) +export class CreateFeedBackDto extends createZodDto(CreateFeedBackSchema) {} + +export const GetFeedbackListSchema = z.object({ + time: z + .array(z.string()) + .optional() + .describe('时间范围数组,格式为[startDate, endDate],格式YYYY-MM-DD'), + userId: z.string().optional().describe('用户ID'), + type: z.enum(FeedbackType).optional().describe('反馈类型'), +}) +export class GetFeedbackListDto extends createZodDto(GetFeedbackListSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.controller.ts new file mode 100644 index 000000000..0c4e9430b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.controller.ts @@ -0,0 +1,45 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 反馈 + */ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { UserService } from '../user/user.service' +import { CreateFeedBackDto } from './dto/feedback.dto' +import { FeedbackService } from './feedback.service' + +@ApiTags('反馈') +@Controller('feedback') +export class FeedbackController { + constructor( + private readonly feedbackService: FeedbackService, + private readonly userService: UserService, + ) {} + + @ApiOperation({ + description: '提交反馈', + summary: '提交反馈', + }) + @Post() + async createFeedback( + @GetToken() token: TokenInfo, + @Body() body: CreateFeedBackDto, + ) { + const { fileUrlList, content, type, tagList } = body + const userInfo = await this.userService.getUserInfoById(token.id) + + const res = await this.feedbackService.createFeedback({ + content, + userId: userInfo.id, + userName: userInfo.name, + fileUrlList, + type, + tagList, + }) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.module.ts new file mode 100644 index 000000000..60f16759d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { FeedbackController } from './feedback.controller' +import { FeedbackService } from './feedback.service' + +@Module({ + imports: [], + providers: [FeedbackService], + controllers: [FeedbackController], +}) +export class FeedbackModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.service.ts new file mode 100644 index 000000000..19c4a6034 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/feedback/feedback.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common' +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: + */ +import { Feedback, FeedbackRepository } from '@yikart/mongodb' + +@Injectable() +export class FeedbackService { + constructor(private readonly feedbackRepository: FeedbackRepository, + ) { } + + async createFeedback(newData: Partial) { + const res = await this.feedbackRepository.create(newData) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/file/dto/file.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/dto/file.dto.ts new file mode 100644 index 000000000..bb2c434e8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/dto/file.dto.ts @@ -0,0 +1,40 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const InitMultipartUploadSchema = z.object({ + fileName: z.string().describe('文件名称'), + secondPath: z.string().describe('存放位置'), + fileSize: z.string().describe('文件大小'), + contentType: z.string().describe('文件类型'), +}) +export class InitMultipartUploadDto extends createZodDto(InitMultipartUploadSchema) {} + +export const UploadPartSchema = z.object({ + fileId: z.string().describe('文件key'), + uploadId: z.string().describe('上传ID'), + partNumber: z.number().describe('分片索引'), +}) +export class UploadPartDto extends createZodDto(UploadPartSchema) {} + +export const CompletePartSchema = z.object({ + fileId: z.string().describe('文件key'), + uploadId: z.string().describe('上传ID'), + parts: z.array(z.object({ + PartNumber: z.number(), + ETag: z.string(), + })).describe('分片'), +}) +export class CompletePartDto extends createZodDto(CompletePartSchema) {} + +const getUploadUrlSchema = z.object({ + key: z.string().describe('文件名'), +}) +export class GetUploadUrlDto extends createZodDto(getUploadUrlSchema) {} + +const getUploadPartUrlSchema = z.object({ + key: z.string().describe('文件名'), + uploadId: z.string().describe('上传ID'), + partNumber: z.number().describe('分片序号'), + expiresIn: z.number().optional().describe('过期时间'), +}) +export class GetUploadPartUrlUrlDto extends createZodDto(getUploadPartUrlSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.controller.ts new file mode 100644 index 000000000..dcd81fb89 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.controller.ts @@ -0,0 +1,140 @@ +/* + * @Author: nevin + * @Date: 2022-03-07 13:37:06 + * @LastEditors: nevin + * @LastEditTime: 2024-12-22 21:58:14 + * @Description: 文件 + */ +import { + Body, + Controller, + Get, + Headers, + Post, + Query, + UploadedFile, + UseInterceptors, +} from '@nestjs/common' +import { FileInterceptor } from '@nestjs/platform-express' +import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger' +import { Public } from '@yikart/aitoearn-auth' +import { + CompletePartDto, + GetUploadUrlDto, + InitMultipartUploadDto, + UploadPartDto, +} from './dto/file.dto' +import { FileService } from './file.service' + +@ApiTags('文件') +@Public() +@Controller('file') +export class FileController { + constructor(private readonly fileService: FileService) {} + + @ApiOperation({ description: '获取上传的签名URL', summary: '获取上传的签名URL' }) + @Get('uploadUrl') + async getUploadUrl( + @Query() query: GetUploadUrlDto, + ) { + const url = await this.fileService.getUploadUrl( + query.key, + ) + return url + } + + @ApiOperation({ description: '存入临时目录', summary: '上传文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @UploadedFile() file: any, + @Headers() headers: any, + ) { + const secondPath: string = headers['second-path'] + return await this.fileService.upFileStream(file, secondPath) + } + + @ApiOperation({ + description: '初始化文件分片上传', + summary: '初始化文件分片上传', + }) + @Post('uploadPart/init') + async initiateMultipartUpload(@Body() body: InitMultipartUploadDto) { + return await this.fileService.initiateMultipartUpload( + body.secondPath, + body.contentType, + ) + } + + // @ApiOperation({ description: '获取分片上传的签名URL', summary: '获取分片上传的签名URL' }) + // @Get('uploadUrl') + // async getUploadPartUrl( + // @Query() query: GetUploadPartUrlUrlDto, + // ) { + // const url = await this.fileService.getUploadPartUrl( + // query.key, + // query.uploadId, + // query.partNumber, + // query.expiresIn, + // ) + // return { url, key: query.key } + // } + + @ApiOperation({ description: '上传文件分片', summary: '上传文件分片' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + fileId: { + type: 'string', + }, + uploadId: { + type: 'string', + }, + partNumber: { + type: 'number', + }, + }, + }, + }) + @Post('uploadPart/upload') + @UseInterceptors(FileInterceptor('file')) + async uploadPart( + @UploadedFile() file: any, + @Query() query: UploadPartDto, + ) { + return await this.fileService.uploadPart( + query.fileId, + query.uploadId, + query.partNumber, + file.buffer, + ) + } + + @ApiOperation({ description: '合并文件分片', summary: '合并文件分片' }) + @Post('uploadPart/complete') + async completeMultipartUpload(@Body() body: CompletePartDto) { + return await this.fileService.completeMultipartUpload( + body.fileId, + body.uploadId, + body.parts, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.module.ts new file mode 100644 index 000000000..fbff43bb3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.module.ts @@ -0,0 +1,23 @@ +/* + * @Author: nevin + * @Date: 2022-03-03 16:50:53 + * @LastEditors: nevin + * @LastEditTime: 2024-06-24 17:48:23 + * @Description: 文件存储 + */ +import { Global, Module } from '@nestjs/common' +import { S3Module } from '@yikart/aws-s3' +import { config } from '../config' +import { FileController } from './file.controller' +import { FileService } from './file.service' + +@Global() +@Module({ + imports: [ + S3Module.forRoot(config.awsS3), + ], + controllers: [FileController], + providers: [FileService], + exports: [FileService], +}) +export class FileModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.service.ts new file mode 100644 index 000000000..90e6426a9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/file/file.service.ts @@ -0,0 +1,193 @@ +import { Injectable } from '@nestjs/common' +import { S3Service } from '@yikart/aws-s3' +import dayjs from 'dayjs' +import * as mime from 'mime-types' +import { v4 as uuidv4 } from 'uuid' +import { config } from '../config' + +@Injectable() +export class FileService { + constructor(private readonly s3Service: S3Service) { } + + private getNewFilePath(opt: { + path: string + newName?: string + permanent?: boolean + }) { + let { path, newName } = opt + + path = `${config.environment}/${opt.permanent ? '' : 'temp/'}${path || `nopath/${dayjs().format('YYYYMM')}`}` + path = path.replace('//', '/') + newName = newName || uuidv4() + + return { + path, + newName, + } + } + + /** + * 文件上传 + * @param {Express.Multer.File} file 文件buffer流对象 + * @param {string | undefined} path 路径,不传就会使用‘nopath’前缀 + * @param {string | undefined} newName 新的文件名 + * @param {string | undefined} permanent 是否为永久目录,默认临时 + * @returns + */ + async upFileStream( + file: any, + path: string, + newName?: string, + permanent?: boolean, + ) { + const { path: newPath, newName: newFileName } = this.getNewFilePath({ + path, + newName, + permanent, + }) + const filePath = `${newPath}/${newFileName}.${mime.extension(file.mimetype)}` + const res = await this.s3Service.putObject( + filePath, + file.buffer, + ) + return { key: res.path } + } + + /** + * 上传二进制流文件 + * @param buffer 二进制流 base64格式 + * @param option + * @param option.path 路径 + * @param option.permanent 是否为永久目录,默认临时 + * @param option.fileType 文件后缀 + */ + async uploadByStream( + buffer: Buffer, // base64格式(不带前缀) + option: { + path?: string + permanent?: boolean + fileType: string + }, + ): Promise { + const { path, permanent, fileType } = option + const objectName = `${config.environment}/${permanent ? '' : 'temp/'}${path || 'nopath'}${`/${dayjs().format('YYYYMM')}/${uuidv4()}.${fileType}`}` + const res = await this.s3Service.putObject( + objectName, + buffer, + ) + + return res.path + } + + /** + * 初始化分片 + * @param path + * @param fileType + * @returns + */ + async initiateMultipartUpload(path: string, fileType: string) { + const { path: newPath, newName: newFileName } = this.getNewFilePath({ + path, + }) + const filePath = `${newPath}/${newFileName}.${mime.extension(fileType)}` + const res = await this.s3Service.initiateMultipartUpload(filePath) + return { + uploadId: res, + fileId: filePath, + } + } + + /** + * 上传分片数据 + * @param fileId + * @param uploadId + * @param partNumber + * @param partData + * @returns + */ + async uploadPart( + fileId: string, + uploadId: string, + partNumber: number, + partData: Buffer, + ) { + const res = await this.s3Service.uploadPart( + fileId, + uploadId, + partNumber, + partData, + ) + + return { + PartNumber: partNumber, + ETag: res.ETag, + } + } + + /** + * 合并分片 + * @param key + * @param uploadId + * @param parts + * @returns + */ + async completeMultipartUpload( + key: string, + uploadId: string, + parts: { PartNumber: number, ETag: string }[], + ) { + const res = await this.s3Service.completeMultipartUpload( + key, + uploadId, + parts, + ) + + return res + } + + /** + * 获取分片上传的签名URL + * @param key + * @param contentType + * @param expiresIn + * @returns + */ + async getUploadUrl( + key: string, + ) { + const presigned = await this.s3Service.getUploadSign( + key, + ) + return presigned + } + + // /** + // * 获取分片上传的签名URL + // * @param key + // * @param contentType + // * @param expiresIn + // * @returns + // */ + // async getUploadPartUrl( + // key: string, + // uploadId: string, + // partNumber: number, + // expiresIn?: number, + // ) { + // try { + // const url = await this.s3Service.getUploadPartUrl( + // key, + // uploadId, + // partNumber, + // { + // expiresIn, + // }, + // ) + // return { url, key } + // } + // catch (error) { + // Logger.error(error) + // throw new AppException(ErrHttpBack.fail) + // } + // } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/dto/fingerprint.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/dto/fingerprint.dto.ts new file mode 100644 index 000000000..18e42029c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/dto/fingerprint.dto.ts @@ -0,0 +1,2 @@ +export class GenerateFingerprintDto { +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.controller.ts new file mode 100644 index 000000000..1281e9d43 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.controller.ts @@ -0,0 +1,19 @@ +import { Body, Controller } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GenerateFingerprintDto } from './dto/fingerprint.dto' +import { FingerprintService } from './fingerprint.service' + +@ApiTags('浏览器指纹') +@Controller('fingerprint') +export class FingerprintController { + constructor(private readonly fingerprintService: FingerprintService) { } + + @ApiOperation({ + description: '生成随机浏览器指纹', + }) + // @NatsMessagePattern('account.fingerprint.generateFingerprint') + + async generateFingerprint(@Body() _data: GenerateFingerprintDto) { + return null + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.module.ts new file mode 100644 index 000000000..10e2dcdcd --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common' +import { FingerprintController } from './fingerprint.controller' +import { FingerprintService } from './fingerprint.service' + +@Global() +@Module({ + providers: [FingerprintService], + controllers: [FingerprintController], +}) +export class FingerprintModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.service.ts new file mode 100644 index 000000000..96efd6413 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/fingerprint/fingerprint.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common' +import { FingerprintGenerator } from 'fingerprint-generator' +import { Device } from 'header-generator/header-generator' + +@Injectable() +export class FingerprintService { + constructor( + ) {}; + + async generateFingerprintCore(devices: Device) { + const generator = new FingerprintGenerator() + return generator.getFingerprint({ + devices: [devices], + }) + } + + /** + * 生成随机浏览器指纹 + * @returns 浏览器指纹 + */ + async generateFingerprint() { + const mobile = await this.generateFingerprintCore('mobile') + const desktop = await this.generateFingerprintCore('desktop') + return { + mobile, + desktop, + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/dto/income.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/dto/income.dto.ts new file mode 100644 index 000000000..1bded7179 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/dto/income.dto.ts @@ -0,0 +1,53 @@ +import { createZodDto, TableDtoSchema } from '@yikart/common' +import { IncomeType } from '@yikart/mongodb' +import { z } from 'zod' + +export const IncomeIdSchema = z.object({ + id: z.string().describe('ID'), +}) +export class IncomeIdDto extends createZodDto(IncomeIdSchema) {} + +export const IncomeFilterSchema = z.object({ + status: z.enum(IncomeType).optional().describe('状态'), +}) +export class IncomeFilterDto extends createZodDto(IncomeFilterSchema) {} + +export const IncomeListSchema = z.object({ + filter: IncomeFilterSchema.optional(), + page: TableDtoSchema, +}) +export class IncomeListDto extends createZodDto(IncomeListSchema) {} + +export const withdrawCreateSchema = z.object({ + flowId: z.string().min(1).optional(), + userWalletAccountId: z.string().min(1).describe('用户钱包账户ID'), + incomeRecordId: z.string().min(1).describe('收入记录ID'), +}) +export class WithdrawCreateDto extends createZodDto(withdrawCreateSchema) {} + +export const withdrawCreateAllSchema = z.object({ + userWalletAccountId: z.string(), +}) +export class WithdrawCreateAllDto extends createZodDto(withdrawCreateAllSchema) {} + +export const addIncomeSchema = z.object({ + userId: z.string(), + amount: z.number(), + type: z.enum(IncomeType), + description: z.string().optional(), + metadata: z.any().optional(), + relId: z.string().optional(), + withdrawId: z.string().optional(), +}) +export class AddIncomeSchemaDto extends createZodDto(addIncomeSchema) {} + +export const DeductIncomeSchema = z.object({ + userId: z.string(), + amount: z.number(), + type: z.enum(IncomeType), + description: z.string().optional(), + metadata: z.any().optional(), + relId: z.string().optional(), + withdrawId: z.string().optional(), +}) +export class DeductIncomeDto extends createZodDto(DeductIncomeSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.controller.ts new file mode 100644 index 000000000..767035a9d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.controller.ts @@ -0,0 +1,81 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode, TableDto } from '@yikart/common' +import { IncomeStatus } from '@yikart/mongodb' +import { UserWalletAccountService } from '../user/userWalletAccount.service' +import { IncomeFilterDto, WithdrawCreateAllDto, WithdrawCreateDto } from './dto/income.dto' +import { IncomeService } from './income.service' + +@ApiTags('收入') +@Controller('income') +export class IncomeController { + constructor( + private readonly incomeService: IncomeService, + private readonly userWalletAccountService: UserWalletAccountService, + ) { } + + @ApiOperation({ + description: '获取收入列表', + summary: '获取收入列表', + }) + @Get('list/:pageNo/:pageSize') + async getList( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + @Query() query: IncomeFilterDto, + ) { + const res = await this.incomeService.getList(param, { + userId: token.id, + ...query, + }) + return res + } + + @ApiOperation({ + description: '获取收入信息', + summary: '获取收入信息', + }) + @Get('info/:id') + async getInfo( + @GetToken() token: TokenInfo, + @Param('id') id: string, + ) { + const res = await this.incomeService.getInfo(id) + return res + } + + @ApiOperation({ summary: '收入记录创建提现' }) + @Post('withdraw') + async withdraw(@GetToken() token: TokenInfo, @Body() body: WithdrawCreateDto) { + const incomeRecord = await this.incomeService.getInfo(body.incomeRecordId) + if (!incomeRecord || incomeRecord.userId !== token.id) { + throw new AppException(ResponseCode.IncomeRecordNotFound, 'Incom record not found') + } + if (incomeRecord.status !== IncomeStatus.WAIT) { + throw new AppException(ResponseCode.IncomeRecordNotWithdrawable, 'Income record not withdrawable') + } + const userWalletAccount = await this.userWalletAccountService.info(body.userWalletAccountId) + if (!userWalletAccount || userWalletAccount.userId !== token.id) { + throw new AppException(ResponseCode.UserWalletAccountAlreadyExists, 'User wallet account not found') + } + return this.incomeService.withdraw(body.incomeRecordId, body.userWalletAccountId, body.flowId) + } + + @ApiOperation({ summary: '提现全部收入' }) + @Post('withdrawAll') + async withdrawAll(@GetToken() token: TokenInfo, @Body() body: WithdrawCreateAllDto) { + const userWalletAccount = await this.userWalletAccountService.info(body.userWalletAccountId) + if (!userWalletAccount || userWalletAccount.userId !== token.id) { + throw new AppException(ResponseCode.UserWalletAccountAlreadyExists, 'User wallet account not found') + } + return this.incomeService.withdrawAll(token.id, body.userWalletAccountId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.module.ts new file mode 100644 index 000000000..ce1342306 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common' +import { IncomeController } from './income.controller' +import { IncomeService } from './income.service' +import { WithdrawController } from './withdraw.controller' +import { WithdrawService } from './withdraw.service' + +@Module({ + imports: [], + controllers: [IncomeController, WithdrawController], + providers: [IncomeService, WithdrawService], + exports: [IncomeService], +}) +export class IncomeModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.service.ts new file mode 100644 index 000000000..859c91553 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/income.service.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@nestjs/common' +import { AppException, TableDto } from '@yikart/common' +import { IncomeRecordRepository, IncomeStatus, IncomeType, WithdrawRecordType } from '@yikart/mongodb' +import { AddIncomeSchemaDto, DeductIncomeDto } from './dto/income.dto' +import { WithdrawService } from './withdraw.service' + +const getWithdrawRecordTypeMap = new Map([ + [IncomeType.Task, WithdrawRecordType.Task], +]) + +@Injectable() +export class IncomeService { + constructor( + private readonly withdrawService: WithdrawService, + private readonly incomeRecordRepository: IncomeRecordRepository, + ) { } + + /** + * 获取收入信息 + * @param data + * @returns + */ + async add(data: AddIncomeSchemaDto) { + const res = await this.incomeRecordRepository.add(data) + return res + } + + /** + * 获取收入信息 + * @param data + * @returns + */ + async deduct(data: DeductIncomeDto) { + const res = await this.incomeRecordRepository.deduct(data) + return res + } + + /** + * 获取收入信息 + * @param id + * @returns + */ + async getInfo(id: string) { + const res = await this.incomeRecordRepository.getById(id) + return res + } + + /** + * 收入列表 + * @param page + * @param filter + * @param filter.userId + * @param filter.type + * @returns + */ + async getList( + page: TableDto, + filter: { userId: string, type?: IncomeType }, + ) { + const [list, total] = await this.incomeRecordRepository.listWithPagination({ + ...filter, + page: page.pageNo, + pageSize: page.pageSize, + }) + return { + list, + total, + } + } + + /** + * 创建提现 + * @param id + * @param userWalletAccountId + * @param flowId + * @returns + */ + async withdraw(id: string, userWalletAccountId?: string, flowId?: string) { + const incomeInfo = await this.incomeRecordRepository.getById(id) + if (!incomeInfo) { + throw new AppException(10001, 'No income information was found') + } + if (incomeInfo.status !== IncomeStatus.WAIT) { + throw new AppException(10001, 'Income information is not available') + } + const type = getWithdrawRecordTypeMap.get(incomeInfo.type) || WithdrawRecordType.Task + const res = await this.withdrawService.create(incomeInfo.userId, { + flowId, + userWalletAccountId, + type, + amount: incomeInfo.amount, + relId: incomeInfo.relId, + incomeRecordId: incomeInfo.id, + remark: incomeInfo.desc, + metadata: { + incomeRecordId: incomeInfo.id, + }, + }) + this.incomeRecordRepository.withdraw(id, res.id) + return res + } + + /** + * 提现全部收入 + * @param userId + * @param userWalletAccountId + * @returns + */ + async withdrawAll(userId: string, userWalletAccountId: string) { + const res = await this.withdrawService.createAll(userId, userWalletAccountId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.controller.ts new file mode 100644 index 000000000..ca9451ea7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { WithdrawService } from './withdraw.service' + +@ApiTags('withdraw - 提现') +@Controller('withdraw') +export class WithdrawController { + constructor( + private readonly withdrawService: WithdrawService, + ) {} + + @ApiOperation({ summary: '信息' }) + @Get('info/:id') + async getById(@GetToken() token: TokenInfo, @Param('id') id: string) { + return this.withdrawService.getInfo(id) + } + + @ApiOperation({ summary: '列表' }) + @Get('list/:pageNo/:pageSize') + async subscription( + @GetToken() token: TokenInfo, + @Param() param: TableDto, + ) { + return this.withdrawService.getList(param, { userId: token.id }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.service.ts new file mode 100644 index 000000000..27978af0e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/income/withdraw.service.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common' +import { AppException, TableDto } from '@yikart/common' +import { IncomeRecordRepository, WithdrawRecordRepository, WithdrawRecordType } from '@yikart/mongodb' +import { v4 as uuidv4 } from 'uuid' +import { UserWalletAccountService } from '../user/userWalletAccount.service' + +@Injectable() +export class WithdrawService { + constructor( + private readonly withdrawRecordRepository: WithdrawRecordRepository, + private readonly incomeRecordRepository: IncomeRecordRepository, + private readonly userWalletAccountService: UserWalletAccountService, + ) { } + + async create(userId: string, data: { + flowId?: string + userWalletAccountId?: string + type: WithdrawRecordType + amount: number + relId?: string + incomeRecordId?: string + remark?: string + metadata?: Record + }) { + return this.withdrawRecordRepository.create({ userId, ...data }) + } + + async createAll(userId: string, userWalletAccountId: string) { + const incomeRecordList = await this.incomeRecordRepository.getAllWithdrawableIncome(userId) + if (!incomeRecordList?.length) + return true + + const userWalletAccount = await this.userWalletAccountService.info(userWalletAccountId) + if (!userWalletAccount) + throw new AppException(1000, 'The Wallet Account Not Found') + + const flowId = uuidv4() + + for (const incomeRecord of incomeRecordList) { + const oldData = await this.withdrawRecordRepository.getInfoByIncomeId(incomeRecord.id) + if (oldData) + continue + + const incomeInfo = await this.incomeRecordRepository.getRecordInfo(incomeRecord.id) + if (!incomeInfo) + continue + + this.withdrawRecordRepository.create({ + flowId, + userId, + relId: incomeInfo.relId, + userWalletAccountId, + userWalletAccountInfo: userWalletAccount, + amount: incomeInfo.amount, + incomeRecordId: incomeRecord.id, + type: WithdrawRecordType.Task, + }).then((withdrawRecord) => { + this.incomeRecordRepository.withdraw(incomeRecord.id, withdrawRecord.id) + }) + } + + return true + } + + async getInfo(id: string) { + return this.withdrawRecordRepository.getById(id) + } + + async getList(page: TableDto, filter: { userId: string }) { + return this.withdrawRecordRepository.getListOfUser(page, filter) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/account.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/account.controller.ts new file mode 100644 index 000000000..53adff90f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/account.controller.ts @@ -0,0 +1,116 @@ +import { + Body, + Controller, + Get, + Logger, + Param, + Patch, + Post, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { AccountService } from '../account/account.service' +import { AccountIdDto, AccountListByIdsDto, AccountListByParamDto, AccountListByTypesDto, CreateAccountDto, UpdateAccountDto, UpdateAccountStatisticsDto, UpdateAccountStatusDto } from '../account/dto/account.dto' +import { AccountInternalService } from './provider/account.service' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class AccountController { + private readonly logger = new Logger(AccountController.name) + constructor( + private readonly accountInternalService: AccountInternalService, + private readonly accountService: AccountService, + ) { } + + @ApiOperation({ summary: 'create social media accounts' }) + @Post('/:userId/socials/accounts') + async createOrUpdateAccount( + @Param('userId') userId: string, + @Body() body: CreateAccountDto, + ) { + this.logger.log( + `Creating social media account for userId: ${userId} with body: ${JSON.stringify(body)}`, + ) + return await this.accountInternalService.createSocialMediaAccount( + userId, + body, + ) + } + + @ApiOperation({ summary: 'get social media account detail' }) + @Get('/:userId/socials/accounts/:accountId') + async getAccountDetail( + @Param('userId') userId: string, + @Param('accountId') accountId: string, + ) { + return await this.accountInternalService.getAccountDetail( + userId, + accountId, + ) + } + + @ApiOperation({ summary: 'update social media account' }) + @Patch('/:userId/socials/accounts/:accountId') + async updateAccountInfo( + @Param('userId') userId: string, + @Param('accountId') accountId: string, + @Body() body: UpdateAccountDto, + ) { + const res = await this.accountInternalService.updateAccountInfo( + userId, + body, + ) + return res + } + + @ApiOperation({ summary: 'update account insights' }) + @Patch('/socials/accounts/:accountId/statistics') + async updateAccountStatistics( + @Param('accountId') accountId: string, + @Body() body: UpdateAccountStatisticsDto, + ) { + return this.accountInternalService.updateAccountStatistics( + accountId, + body, + ) + } + + @ApiOperation({ summary: 'get channel info' }) + @Post('account/info') + async getAccountInfoToTask(@Body() body: AccountIdDto) { + return this.accountService.getAccountById(body.id) + } + + @ApiOperation({ summary: 'get channel list(by ids)' }) + @Post('account/list/ids') + async getAccountListByIds( + @Body() body: AccountListByIdsDto, + ) { + return this.accountService.getAccountListByIds(body.ids) + } + + @ApiOperation({ summary: 'get channel list(by types)' }) + @Post('account/list/types') + async getAccountListByTypes( + @Body() body: AccountListByTypesDto, + ) { + return this.accountService.getAccountsByTypes(body.types, body.status) + } + + @ApiOperation({ summary: 'get channel list(by param)' }) + @Post('account/list/param') + async getAccountListByParam( + @Body() body: AccountListByParamDto, + ) { + return this.accountService.getAccountByParam(body) + } + + @ApiOperation({ summary: 'update account status' }) + @Post('account/update/status') + async updateAccountStatus( + @Body() body: UpdateAccountStatusDto, + ) { + return this.accountService.updateAccountStatus(body.id, body.status) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/ai.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/ai.controller.ts new file mode 100644 index 000000000..68b04ed72 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/ai.controller.ts @@ -0,0 +1,87 @@ +import { + Body, + Controller, + Post, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { UserType } from '@yikart/common' +import { AiService } from '../ai/ai.service' +import { FireflycardResponseVo, ImageResponseVo, ListVideoTasksResponseVo, VideoGenerationResponseVo, VideoTaskStatusResponseVo } from '../ai/ai.vo' +import { ChatCompletionVo, ChatService, UserChatCompletionDto } from '../ai/core/chat' +import { AdminFireflyCardDto, AdminImageGenerationDto, AdminUserListVideoTasksQueryDto, AdminVideoGenerationRequestDto, AdminVideoGenerationStatusSchemaDto } from './dto/ai.dto' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class AiController { + constructor( + private readonly chatService: ChatService, + private readonly aiService: AiService, + ) { } + + @ApiOperation({ summary: 'create publish record' }) + @Post('ai/chat/completion') + async chatCompletion(@Body() data: UserChatCompletionDto): Promise { + const response = await this.chatService.userChatCompletion(data) + return ChatCompletionVo.create(response) + } + + @ApiOperation({ summary: '获取图片生成模型参数' }) + @Post('ai/models/image/generation') + async getImageGenerationModels(@Body() body: { + userId: string + userType: UserType + }) { + const response = await this.aiService.getImageGenerationModels({ + userId: body.userId, + userType: body.userType, + }) + return response + } + + @ApiOperation({ summary: 'AI图片生成' }) + @Post('ai/image/generate') + async generateImage( + @Body() body: AdminImageGenerationDto, + ): Promise { + const response = await this.aiService.userImageGeneration(body) + return ImageResponseVo.create(response) + } + + @ApiOperation({ summary: '通用视频生成' }) + @Post('ai/video/generations') + async videoGeneration( + @Body() body: AdminVideoGenerationRequestDto, + ): Promise { + const response = await this.aiService.userVideoGeneration(body) + return VideoGenerationResponseVo.create(response) + } + + @ApiOperation({ summary: '查询视频任务状态' }) + @Post('ai/video/status') + async getVideoTaskStatus(@Body() body: AdminVideoGenerationStatusSchemaDto): Promise { + const response = await this.aiService.getVideoTaskStatus({ + userId: body.userId, + userType: body.userType, + taskId: body.taskId, + }) + return VideoTaskStatusResponseVo.create(response) + } + + @ApiOperation({ summary: '视频任务列表' }) + @Post('ai/video/list') + async listVideoTasks(@Body() body: AdminUserListVideoTasksQueryDto): Promise { + const response = await this.aiService.listVideoTasks(body) + return ListVideoTasksResponseVo.create(response) + } + + @ApiOperation({ summary: 'Fireflycard生成卡片图片' }) + @Post('ai/fireflycard') + async generateFireflycard( + @Body() body: AdminFireflyCardDto, + ): Promise { + const response = await this.aiService.generateFireflycard(body) + return FireflycardResponseVo.create(response) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/cloud-space.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/cloud-space.controller.ts new file mode 100644 index 000000000..58f931fee --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/cloud-space.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { Internal } from '@yikart/aitoearn-auth' +import { CloudSpaceService, CreateCloudSpaceDto, DeleteCloudSpaceDto, GetCloudSpaceStatusDto, ListCloudSpacesByUserIdDto, ListCloudSpacesDto, RenewCloudSpaceDto, RetryCloudSpaceDto } from '../cloud/core/cloud-space' +import { CloudSpaceListVo, CloudSpaceVo } from '../cloud/core/cloud-space/cloud-space.vo' + +@Controller('internal') +@Internal() +export class CloudSpaceController { + constructor(private readonly cloudSpaceService: CloudSpaceService) {} + + // @NatsMessagePattern('cloud-space.create') + @Post('cloud-space/create') + async createCloudSpace(@Body() dto: CreateCloudSpaceDto): Promise { + const cloudSpace = await this.cloudSpaceService.createCloudSpace(dto) + return CloudSpaceVo.create(cloudSpace as any) + } + + // @NatsMessagePattern('cloud-space.list') + @Post('cloud-space/list') + async listCloudSpaces(@Body() dto: ListCloudSpacesDto): Promise { + const [cloudSpaces, total] = await this.cloudSpaceService.listCloudSpaces(dto) + return new CloudSpaceListVo(cloudSpaces as any, total, dto) + } + + // @NatsMessagePattern('cloud-space.listByUserId') + @Post('cloud-space/listByUserId') + async listCloudSpacesByUserId(@Body() dto: ListCloudSpacesByUserIdDto): Promise { + const cloudSpaces = await this.cloudSpaceService.listCloudSpacesByUserId(dto) + return cloudSpaces.map(c => CloudSpaceVo.create(c)) + } + + // @NatsMessagePattern('cloud-space.status') + @Post('cloud-space/status') + async getCloudSpaceStatus(@Body() dto: GetCloudSpaceStatusDto): Promise { + const cloudSpace = await this.cloudSpaceService.getCloudSpaceStatus(dto.cloudSpaceId) + return CloudSpaceVo.create(cloudSpace) + } + + // @NatsMessagePattern('cloud-space.renew') + @Post('cloud-space/renew') + async renewCloudSpace(@Body() dto: RenewCloudSpaceDto) { + await this.cloudSpaceService.renewCloudSpace(dto) + } + + // @NatsMessagePattern('cloud-space.retry') + @Post('cloud-space/retry') + async retryCloudSpace(@Body() dto: RetryCloudSpaceDto) { + await this.cloudSpaceService.retryCloudSpace(dto) + } + + // @NatsMessagePattern('cloud-space.delete') + @Post('cloud-space/delete') + async deleteCloudSpace(@Body() dto: DeleteCloudSpaceDto) { + await this.cloudSpaceService.deleteCloudSpace(dto.cloudSpaceId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/dto/ai.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/dto/ai.dto.ts new file mode 100644 index 000000000..06e8bd263 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/dto/ai.dto.ts @@ -0,0 +1,62 @@ +import { createZodDto, PaginationDtoSchema, UserType } from '@yikart/common' +import { z } from 'zod' +import { fireflycardStyleSchema, fireflycardSwitchConfigSchema, fireflycardTempSchema } from '../../ai/dto' +import { FireflycardTempTypes } from '../../ai/libs/fireflycard' + +// 图片生成请求 +const AdminImageGenerationSchema = z.object({ + prompt: z.string().min(1).max(4000).describe('图片描述提示'), + model: z.string().describe('图片生成模型'), + n: z.number().int().min(1).max(10).optional().describe('生成图片数量'), + quality: z.string().optional().describe('图片质量'), + response_format: z.enum(['url', 'b64_json']).optional().describe('返回格式'), + size: z.string().optional().describe('图片尺寸'), + style: z.string().optional().describe('图片风格'), + user: z.string().optional().describe('用户标识符'), + userId: z.string().describe('用户Id'), + userType: z.enum(UserType).describe('用户类型'), +}) +export class AdminImageGenerationDto extends createZodDto(AdminImageGenerationSchema) { } + +// 通用视频生成请求 +const videoGenerationRequestSchema = z.object({ + model: z.string().min(1).describe('模型名称'), + userId: z.string().describe('用户Id'), + userType: z.enum(UserType).describe('用户类型'), + prompt: z.string().min(1).max(4000).describe('提示词'), + image: z.string().or(z.string().array()).optional().describe('图片URL或base64'), + image_tail: z.string().optional().describe('尾帧图片URL或base64'), + mode: z.string().optional().describe('生成模式'), + size: z.string().optional().describe('尺寸'), + duration: z.number().optional().describe('时长'), + metadata: z.record(z.string(), z.any()).optional().describe('其他参数'), +}) +export class AdminVideoGenerationRequestDto extends createZodDto(videoGenerationRequestSchema) { } + +const AdminVideoGenerationStatusSchema = z.object({ + userId: z.string().describe('用户Id'), + userType: z.enum(UserType).describe('用户类型'), + taskId: z.string().describe('任务ID'), +}) +export class AdminVideoGenerationStatusSchemaDto extends createZodDto(AdminVideoGenerationStatusSchema) { } + +// 通用视频任务状态查询 +const adminListUserVideoTasksQuerySchema = z.object({ + ...PaginationDtoSchema.shape, + userId: z.string().describe('用户Id'), + userType: z.enum(UserType).describe('用户类型'), +}) + +export class AdminUserListVideoTasksQueryDto extends createZodDto(adminListUserVideoTasksQuerySchema) {} + +const adminFireflyCardSchema = z.object({ + content: z.string().min(1).describe('卡片内容'), + temp: fireflycardTempSchema.default(FireflycardTempTypes.A).describe('模板类型'), + title: z.string().optional().describe('标题'), + style: fireflycardStyleSchema.describe('样式配置'), + switchConfig: fireflycardSwitchConfigSchema.describe('开关配置'), + userId: z.string().describe('用户Id'), + userType: z.enum(UserType).describe('用户类型'), +}) + +export class AdminFireflyCardDto extends createZodDto(adminFireflyCardSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/income.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/income.controller.ts new file mode 100644 index 000000000..02f754096 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/income.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { AddIncomeSchemaDto, DeductIncomeDto } from '../income/dto/income.dto' +import { IncomeService } from '../income/income.service' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class IncomeInternalController { + constructor(private readonly incomeService: IncomeService) { } + + @ApiOperation({ summary: '增加收入' }) + @Post('income/add') + async add(@Body() body: AddIncomeSchemaDto) { + return this.incomeService.add(body) + } + + @ApiOperation({ summary: '扣减收入' }) + @Post('income/deduct') + async deduct(@Body() body: DeductIncomeDto) { + return this.incomeService.deduct(body) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/internal.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/internal.module.ts new file mode 100644 index 000000000..de1d19531 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/internal.module.ts @@ -0,0 +1,46 @@ +import { Module } from '@nestjs/common' +import { AccountModule } from '../account/account.module' +import { AiModule } from '../ai/ai.module' +import { ChatModule } from '../ai/core/chat' +import { CloudSpaceModule } from '../cloud/core/cloud-space' +import { ContentModule } from '../content/content.module' +import { IncomeModule } from '../income/income.module' +import { NotificationModule } from '../notification/notification.module' +import { PublishModule } from '../publishRecord/publishRecord.module' +import { UserModule } from '../user/user.module' +import { AccountController } from './account.controller' +import { AiController } from './ai.controller' +import { CloudSpaceController } from './cloud-space.controller' +import { IncomeInternalController } from './income.controller' +import { MaterialInternalController } from './material.controller' +import { NotificationInternalController } from './notification.controller' +import { AccountInternalService } from './provider/account.service' +import { PublishingInternalService } from './provider/publishing.service' +import { PublishingController } from './publishing.controller' +import { UserInternalController } from './user.controller' + +@Module({ + imports: [ + UserModule, + AccountModule, + CloudSpaceModule, + ChatModule, + PublishModule, + IncomeModule, + NotificationModule, + ContentModule, + AiModule, + ], + providers: [AccountInternalService, PublishingInternalService], + controllers: [ + UserInternalController, + AccountController, + AiController, + CloudSpaceController, + IncomeInternalController, + NotificationInternalController, + PublishingController, + MaterialInternalController, + ], +}) +export class InternalModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/material.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/material.controller.ts new file mode 100644 index 000000000..94479dcb8 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/material.controller.ts @@ -0,0 +1,125 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { AppException } from '@yikart/common' +import { MaterialType, MediaType } from '@yikart/mongodb' +import { NewMaterial, NewMaterialGroup } from '../content/common' +import { CreateMaterialTaskDto, MaterialIdsDto, MaterialListDto } from '../content/dto/material.dto' +import { MaterialService } from '../content/material.service' +import { MaterialGroupService } from '../content/materialGroup.service' +import { MaterialTaskService } from '../content/materialTask.service' +import { MediaGroupService } from '../content/mediaGroup.service' + +export const MediaMaterialTypeMap = new Map([ + [MediaType.VIDEO, MaterialType.VIDEO], + [MediaType.IMG, MaterialType.ARTICLE], +]) + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class MaterialInternalController { + constructor( + private readonly materialService: MaterialService, + private readonly materialGroupService: MaterialGroupService, + private readonly materialTaskService: MaterialTaskService, + private readonly mediaGroupService: MediaGroupService, + ) { } + + @ApiOperation({ summary: '根据ID列表获取素材列表' }) + @Post('material/list/ids') + async getList( + @Body() body: MaterialIdsDto, + ) { + const res = await this.materialService.getListByIds(body.ids) + return res + } + + @ApiOperation({ summary: '根据ID列表获取最优素材' }) + @Post('material/optimalByIds') + async optimalByIds( + @Body() body: MaterialIdsDto, + ) { + const res = await this.materialService.getListByIds(body.ids) + return res + } + + @ApiOperation({ summary: '获取素材组信息' }) + @Post('material/group/info') + async groupInfo(@Body() body: { id: string }) { + const res = await this.materialGroupService.getGroupInfo(body.id) + return res + } + + @ApiOperation({ summary: '组内获取最优素材' }) + @Post('material/group/optimal') + async optimalInGroup(@Body() body: { groupId: string }) { + const res = await this.materialService.optimalInGroup(body.groupId) + return res + } + + @ApiOperation({ summary: '根据UserId获取草稿箱组列表' }) + @Post('material/group/list/userId') + async getGroupListByUserId(@Body() body: MaterialListDto & { userId: string }) { + const res = await this.materialGroupService.getGroupList(body.page, { + userId: body.userId, + title: body?.filter?.title, + }) + return res + } + + @ApiOperation({ summary: '创建草稿箱组' }) + @Post('material/group/create') + async createMaterialGroup(@Body() body: NewMaterialGroup) { + const res = await this.materialGroupService.createGroup(body) + return res + } + + @ApiOperation({ summary: '创建草稿' }) + @Post('content/material/create') + async createMaterial(@Body() body: NewMaterial) { + const getInfo = await this.materialGroupService.getGroupInfo(body.groupId) + if (!getInfo) { + throw new AppException(1000, '素材组不存在') + } + const res = await this.materialService.create(body) + return res + } + + @ApiOperation({ summary: '创建批量生成草稿任务' }) + @Post('content/material/createTask') + async createTask(@Body() body: CreateMaterialTaskDto) { + const mediaGroupInfo = await this.mediaGroupService.getInfo(body.mediaGroups[0]) + if (!mediaGroupInfo) { + throw new Error('素材组不存在') + } + + const type = mediaGroupInfo?.type as MediaType + const res = await this.materialTaskService.createTask({ + ...body, + type: MediaMaterialTypeMap.get(type)!, + }) + return res + } + + @ApiOperation({ summary: '预览草稿生成任务' }) + @Get('content/material/preview/:id') + async previewTask(@Param('id') id: string) { + const res = await this.materialTaskService.previewTask(id) + return res + } + + @ApiOperation({ summary: '开始草稿生成任务' }) + @Get('content/material/start/:id') + async startTask(@Param('id') id: string) { + const res = await this.materialTaskService.startTask(id) + return res + } + + @ApiOperation({ summary: '增加素材使用计数' }) + @Post('material/use/increase') + async increaseMaterialUse(@Body() body: { id: string }) { + await this.materialService.addUseCount(body.id) + return true + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/notification.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/notification.controller.ts new file mode 100644 index 000000000..af71652cc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/notification.controller.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { CreateToUserDto } from '../notification/notification.dto' +import { NotificationService } from '../notification/notification.service' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class NotificationInternalController { + constructor(private readonly notificationService: NotificationService) { } + + @ApiOperation({ summary: '创建到用户的通知' }) + @Post('notification/createForUser') + async createToUser( + @Body() body: CreateToUserDto, + ) { + const res = await this.notificationService.createForUser( + body, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/account.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/account.service.ts new file mode 100644 index 000000000..d77f9a99a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/account.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountService } from '../../account/account.service' +import { CreateAccountDto, UpdateAccountDto, UpdateAccountStatisticsDto } from '../../account/dto/account.dto' + +@Injectable() +export class AccountInternalService { + private readonly logger = new Logger(AccountInternalService.name) + constructor( + private readonly accountService: AccountService, + ) { } + + async createSocialMediaAccount(userId: string, body: CreateAccountDto) { + return await this.accountService.addAccount(userId, { + ...body, + }) + } + + async getAccountDetail(userId: string, accountId: string) { + return await this.accountService.getAccountById( + accountId, + ) + } + + async updateAccountInfo(userId: string, body: UpdateAccountDto) { + const res = await this.accountService.updateAccountInfoById(body.id, { + userId, + ...body, + }) + return res + } + + async updateAccountStatistics(userId: string, body: UpdateAccountStatisticsDto) { + const { + id, + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + } = body + return this.accountService.updateAccountStatistics( + id, + { + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + }, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/publishing.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/publishing.service.ts new file mode 100644 index 000000000..e8e6e6dc7 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/provider/publishing.service.ts @@ -0,0 +1,58 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AccountType } from '@yikart/common' +import { PublishRecord, PublishStatus } from '@yikart/mongodb' +import { + GetPublishRecordDetailDto, +} from '../../publishRecord/dto/publish.dto' +import { PublishRecordService } from '../../publishRecord/publishRecord.service' + +@Injectable() +export class PublishingInternalService { + private readonly logger = new Logger(PublishingInternalService.name) + constructor( + private readonly publishingService: PublishRecordService, + ) { } + + async createPublishRecord(data: Partial) { + return await this.publishingService.createPublishRecord(data) + } + + async getPublishRecordInfo(id: string) { + return this.publishingService.getPublishRecordInfo(id) + } + + async getPublishRecordByDataId(accountType: AccountType, dataId: string) { + return this.publishingService.getPublishRecordByDataId(accountType, dataId) + } + + async getPublishRecordByDataIdAndUid(uid: string, dataId: string) { + return this.publishingService.getPublishRecordByDataIdAndUid(uid, dataId) + } + + async getPublishRecordDetail(data: GetPublishRecordDetailDto) { + const publishRecord = await this.publishingService.getPublishRecordInfo(data.flowId) + return publishRecord + } + + async getPublishRecordByTaskId(taskId: string, userId: string) { + const res = await this.publishingService.getPublishRecordByTaskId(taskId, userId) + return res + } + + async completePublishTask( + filter: { dataId: string, uid: string }, + data: { + workLink?: string + dataOption?: unknown + }, + ): Promise { + return this.publishingService.donePublishRecord( + filter, + data, + ) + } + + async updatePublishRecordStatus(id: string, status: PublishStatus, errorMsg?: string) { + return this.publishingService.updatePublishRecordStatus(id, status, errorMsg) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/publishing.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/publishing.controller.ts new file mode 100644 index 000000000..70c8c19d0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/publishing.controller.ts @@ -0,0 +1,80 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { PublishRecord, PublishStatus } from '@yikart/mongodb' +import { PublishingInternalService } from './provider/publishing.service' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class PublishingController { + constructor(private readonly publishingInternalService: PublishingInternalService) { } + + @ApiOperation({ summary: 'create publish record' }) + @Post('/publishing/records') + async createPublishRecord( + @Body() body: Partial, + ) { + return await this.publishingInternalService.createPublishRecord( + body, + ) + } + + @ApiOperation({ summary: 'get publish record info' }) + @Get('/publishing/records/:recordId') + async getPublishRecordInfo( + @Param('recordId') recordId: string, + ) { + return await this.publishingInternalService.getPublishRecordInfo( + recordId, + ) + } + + @ApiOperation({ summary: 'update publish record status' }) + @Patch('/publishing/records/:recordId/status') + async updatePublishRecordStatus( + @Param('recordId') recordId: string, + @Body() body: { status: PublishStatus, errorMsg?: string }, + ) { + return await this.publishingInternalService.updatePublishRecordStatus( + recordId, + body.status, + body.errorMsg, + ) + } + + @ApiOperation({ summary: 'get publish record by dataId' }) + @Get('/:uid/publishing/records/:dataId') + async getPublishRecordByDataId( + @Param('uid') uid: string, + @Param('dataId') dataId: string, + ) { + return await this.publishingInternalService.getPublishRecordByDataIdAndUid( + uid, + dataId, + ) + } + + @ApiOperation({ summary: 'complete publish task' }) + @Patch('/:uid/publishing/records/:dataId') + async completePublishTask( + @Param('uid') uid: string, + @Param('dataId') dataId: string, + @Body() body: { + workLink?: string + dataOption?: unknown + }, + ) { + return await this.publishingInternalService.completePublishTask( + { dataId, uid }, + body, + ) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/user.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/user.controller.ts new file mode 100644 index 000000000..6b71550c1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/internal/user.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { Internal } from '@yikart/aitoearn-auth' +import { VipStatus } from '@yikart/mongodb' +import { AddPointsDto, DeductPointsDto } from '../user/dto/points.dto' +import { PointsService } from '../user/points.service' +import { UserService } from '../user/user.service' +import { VipService } from '../user/vip.service' + +@ApiTags('内部服务接口') +@Controller('internal') +@Internal() +export class UserInternalController { + constructor( + private readonly userService: UserService, + private readonly vipService: VipService, + private readonly pointsService: PointsService, + ) { } + + @ApiOperation({ summary: '获取用户信息(by id)' }) + @Post('user/info') + getUserInfoById(@Body() body: { id: string }) { + return this.userService.getUserInfoById(body.id) + } + + @ApiOperation({ summary: '设置用户VIP状态' }) + @Post('user/vip/set') + async setVip(@Body() body: { userId: string, status: VipStatus }) { + const res = await this.vipService.setVipInfo(body.userId, body.status) + return res + } + + @ApiOperation({ summary: '增加用户积分' }) + @Post('user/points/add') + async addPoints(@Body() body: AddPointsDto) { + return this.pointsService.addPoints(body) + } + + @ApiOperation({ summary: '扣减用户积分' }) + @Post('user/points/deduct') + async deductPoints(@Body() body: DeductPointsDto) { + return this.pointsService.deductPoints(body) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/main.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/main.ts new file mode 100644 index 000000000..91c81f0c0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/main.ts @@ -0,0 +1,14 @@ +import { join } from 'node:path' +import { startApplication } from '@yikart/common' +import { AppModule } from './app.module' +import { config } from './config' + +startApplication(AppModule, config, { + setupApp: (app) => { + app.enableCors() + + app.setViewEngine('ejs') + app.setBaseViewsDir(join(__dirname, 'views')) + app.useStaticAssets(join(__dirname, 'public')) + }, +}) diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/dto/manager.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/dto/manager.dto.ts new file mode 100644 index 000000000..97d6678ab --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/dto/manager.dto.ts @@ -0,0 +1,7 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const GetUserTokenSchema = z.object({ + userId: z.string(), +}) +export class GetUserTokenDto extends createZodDto(GetUserTokenSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.controller.ts new file mode 100644 index 000000000..36b198647 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.controller.ts @@ -0,0 +1,22 @@ +import { + Body, + Controller, + Post, +} from '@nestjs/common' +import { Internal } from '@yikart/aitoearn-auth' +import { GetUserTokenDto } from './dto/manager.dto' +import { ManagerService } from './manager.service' + +@Internal() +@Controller() +export class ManagerController { + constructor(private readonly managerService: ManagerService) { } + + @Post('internal/manager/getUserToken') + async getUserToken( + @Body() body: GetUserTokenDto, + ) { + const res = await this.managerService.getUserToken(body.userId) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.module.ts new file mode 100644 index 000000000..c08b78c29 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { ManagerController } from './manager.controller' +import { ManagerService } from './manager.service' + +@Module({ + imports: [], + controllers: [ManagerController], + providers: [ManagerService], +}) +export class ManagerModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.service.ts new file mode 100644 index 000000000..6b2767777 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/manager/manager.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@nestjs/common' +import { AitoearnAuthService } from '@yikart/aitoearn-auth' +import { RedisService } from '@yikart/redis' +import { UserService } from '../user/user.service' + +@Injectable() +export class ManagerService { + constructor( + private readonly authService: AitoearnAuthService, + private readonly redisService: RedisService, + private readonly userService: UserService, + ) {} + + /** + * 获取用户Token + * @param userId + * @returns + */ + async getUserToken(userId: string) { + const userInfo = await this.userService.getUserInfoById(userId) + if (!userInfo) + return '' + + const token = this.authService.generateToken(userInfo) + return token + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.controller.ts new file mode 100644 index 000000000..0cf7d1fef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.controller.ts @@ -0,0 +1,99 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Put, + Query, +} from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { + BatchDeleteDto, + GetUnreadCountDto, + MarkAsReadDto, + QueryNotificationsDto, +} from './notification.dto' +import { NotificationService } from './notification.service' +import { + OperationResultVo, + UnreadCountVo, +} from './notification.vo' + +@ApiTags('notification - 通知') +@Controller('notification') +export class NotificationController { + constructor(private readonly notificationService: NotificationService) { } + @ApiOperation({ summary: '获取未读通知数量' }) + @Get('unread-count') + async getUnreadCount( + @GetToken() token: TokenInfo, + @Query() countDto: GetUnreadCountDto, + ): Promise { + const result = await this.notificationService.getUnreadCount( + token.id, + { + type: countDto.type, + }, + ) + return UnreadCountVo.create(result) + } + + @ApiOperation({ summary: '获取用户通知列表' }) + @Get() + async getUserNotifications( + @GetToken() token: TokenInfo, + @Query() queryDto: QueryNotificationsDto, + ) { + const result = await this.notificationService.findByUser(token.id, queryDto) + return result + } + + @ApiOperation({ summary: '获取通知详情' }) + @Get(':id') + async getNotificationDetail( + @GetToken() token: TokenInfo, + @Param('id') id: string, + ) { + const result = await this.notificationService.findById( + id, + token.id, + ) + return result + } + + @ApiOperation({ summary: '标记通知已读' }) + @Put('read') + async markAsRead( + @GetToken() token: TokenInfo, + @Body() markDto: MarkAsReadDto, + ): Promise { + const result = await this.notificationService.markAsRead(token.id, markDto) + return OperationResultVo.create(result) + } + + @ApiOperation({ summary: '标记全部通知已读' }) + @Put('read-all') + async markAllAsRead( + @GetToken() token: TokenInfo, + ): Promise { + const result = await this.notificationService.markAllAsRead(token.id) + return OperationResultVo.create(result) + } + + @ApiOperation({ summary: '删除通知' }) + @Delete() + async deleteNotifications( + @GetToken() token: TokenInfo, + @Body() deleteDto: BatchDeleteDto, + ): Promise { + const result = await this.notificationService.delete( + token.id, + { + notificationIds: deleteDto.notificationIds, + }, + ) + return OperationResultVo.create(result) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.dto.ts new file mode 100644 index 000000000..3ff57aba2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.dto.ts @@ -0,0 +1,46 @@ +import { createZodDto } from '@yikart/common' +import { NotificationStatus, NotificationType } from '@yikart/mongodb' +import { z } from 'zod' + +const queryNotificationsDtoSchema = z.object({ + status: z.enum(NotificationStatus).optional(), + type: z.enum(NotificationType).optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), +}) +export class QueryNotificationsDto extends createZodDto( + queryNotificationsDtoSchema, +) { } + +const notificationDetailDtoSchema = z.object({ + id: z.string().min(1), +}) + +const markAsReadDtoSchema = z.object({ + notificationIds: z.array(z.string().min(1)).min(1), +}) + +const batchDeleteDtoSchema = z.object({ + notificationIds: z.array(z.string().min(1)).min(1), +}) +export class BatchDeleteDto extends createZodDto(batchDeleteDtoSchema) { } + +const getUnreadCountDtoSchema = z.object({ + type: z.enum(NotificationType).optional(), +}) +export class GetUnreadCountDto extends createZodDto(getUnreadCountDtoSchema) { } + +export class NotificationDetailDto extends createZodDto( + notificationDetailDtoSchema, +) { } +export class MarkAsReadDto extends createZodDto(markAsReadDtoSchema) { } + +const CreateToUserSchema = z.object({ + userId: z.string().min(1), + title: z.string().min(1), + content: z.string().min(1), + type: z.enum(NotificationType).default(NotificationType.TaskReminder), + relatedId: z.string(), + data: z.any().optional(), +}) +export class CreateToUserDto extends createZodDto(CreateToUserSchema) { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.interface.ts new file mode 100644 index 000000000..85e8686c5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.interface.ts @@ -0,0 +1,25 @@ +export interface NotificationDetail { + id: string + userId: string + title: string + content: string + type: string + status: string + relatedId: string + createdAt: string + updatedAt: string + readAt?: string +} + +export interface NotificationListResult { + list: NotificationDetail[] + total: number +} + +export interface UnreadCountResult { + count: number +} + +export interface OperationResult { + affectedCount?: number +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.module.ts new file mode 100644 index 000000000..a9bc64a72 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { OneSignalModule } from '@yikart/one-signal' +import { config } from '../config' +import { NotificationController } from './notification.controller' +import { NotificationService } from './notification.service' + +@Module({ + imports: [ + OneSignalModule.register(config.oneSignal), + ], + controllers: [NotificationController], + providers: [NotificationService], + exports: [NotificationService], +}) +export class NotificationModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.service.ts new file mode 100644 index 000000000..d2cc29420 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { NotificationRepository, NotificationStatus, NotificationType } from '@yikart/mongodb' +import { OneSignalService } from '@yikart/one-signal' +import { + BatchDeleteDto, + MarkAsReadDto, + QueryNotificationsDto, +} from './notification.dto' + +@Injectable() +export class NotificationService { + constructor( + private readonly notificationRepository: NotificationRepository, + private readonly oneSignalService: OneSignalService, + ) { } + + async createForUser(data: { + userId: string + title: string + content: string + type: NotificationType + relatedId: string + data?: Record + }) { + const { userId, title, content, type, relatedId } = data + const saved = await this.notificationRepository.create({ + userId, + title, + content, + type, + relatedId, + data, + status: NotificationStatus.Unread, + }) + await this.oneSignalService.pushNotificationToUser([userId], { + headings: { + zh: title, + }, + contents: { + zh: content, + }, + data: { + type, + relatedId, + data, + }, + }) + + return saved + } + + async findByUser(userId: string, queryDto: QueryNotificationsDto) { + return await this.notificationRepository.listWithPagination({ + userId, + page: queryDto.page, + pageSize: queryDto.pageSize, + status: queryDto.status, + type: queryDto.type, + }) + } + + async findById(id: string, userId: string) { + const notification = await this.notificationRepository.getByIdAndUserId(id, userId) + + if (!notification) { + throw new AppException(ResponseCode.NotificationNotFound) + } + + return notification + } + + async markAsRead(userId: string, markDto: MarkAsReadDto) { + return await this.notificationRepository.updateByIdsAsRead(markDto.notificationIds, userId) + } + + async markAllAsRead(userId: string) { + return await this.notificationRepository.updateByUserIdAllAsRead(userId) + } + + async delete(userId: string, deleteDto: BatchDeleteDto) { + return await this.notificationRepository.deleteByIds(deleteDto.notificationIds, userId) + } + + async getUnreadCount(userId: string, filter?: { + type?: NotificationType + }) { + return await this.notificationRepository.countByUserIdUnread(userId, filter) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.vo.ts new file mode 100644 index 000000000..5582d9fea --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/notification/notification.vo.ts @@ -0,0 +1,35 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +const notificationVoSchema = z.object({ + id: z.string(), + userId: z.string(), + title: z.string(), + content: z.string(), + type: z.string(), + status: z.string(), + readAt: z.string().optional(), + relatedId: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) + +const notificationListVoSchema = z.object({ + list: z.array(notificationVoSchema), + total: z.number().int().min(0), +}) + +const unreadCountVoSchema = z.object({ + count: z.number().int().min(0), +}) + +const operationResultVoSchema = z.object({ + affectedCount: z.number().int().min(0).optional(), +}) + +export class NotificationVo extends createZodDto(notificationVoSchema) {} +export class NotificationListVo extends createZodDto( + notificationListVoSchema, +) {} +export class UnreadCountVo extends createZodDto(unreadCountVoSchema) {} +export class OperationResultVo extends createZodDto(operationResultVoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/common.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/dto/publish.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/dto/publish.dto.ts new file mode 100644 index 000000000..a4b2ead4d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/dto/publish.dto.ts @@ -0,0 +1,104 @@ +import { AccountType, createZodDto } from '@yikart/common' +import { PublishStatus, PublishType } from '@yikart/mongodb' +import { z } from 'zod' + +/** + * 创建发布记录 + */ +export const publishRecordIdSchema = z.object({ + id: z.string({ message: 'id' }), +}) +export class PublishRecordIdDto extends createZodDto(publishRecordIdSchema) {} + +export enum BilibiliNoReprint { + No = 1, + Yes = 0, +} +export enum Copyright { + Original = 1, // 原创 + Reprint = 2, +} + +/** + * 创建发布记录 + */ +export const CreatePublishRecordSchema = z.object({ + flowId: z.string({ message: '流水ID' }).optional(), + dataId: z.string({ message: '数据ID' }), + userId: z.string({ message: '用户ID' }), + uid: z.string({ message: '频道账户ID' }), + accountId: z.string({ message: '账户ID' }), + accountType: z.enum(AccountType, { message: '平台类型' }), + type: z.enum(PublishType, { message: '类型' }), + status: z.enum(PublishStatus, { message: '状态' }), + title: z.string().optional(), + desc: z.string().optional(), + userTaskId: z.string({ message: '用户任务ID' }).optional(), // 用户任务ID + taskId: z.string({ message: '任务ID' }).optional(), // 任务ID + taskMaterialId: z.string({ message: '任务素材ID' }).optional(), // 任务素材ID + videoUrl: z.string().optional(), + coverUrl: z.string().optional(), + imgUrlList: z.array(z.string()).optional(), + publishTime: z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + topics: z.array(z.string()), + workLink: z.string({ message: '作品链接' }).optional(), + option: z.object().optional(), +}) +export class CreatePublishRecordDto extends createZodDto(CreatePublishRecordSchema) {} + +export const PublishRecordListFilterSchema = z.object({ + userId: z.string({ message: '用户ID' }), + accountId: z.string({ message: '账户ID' }).optional(), + uid: z.string({ message: '第三方平台id' }).optional(), + accountType: z.enum(AccountType, { message: '账户类型' }).optional(), + type: z.enum(PublishType, { message: '类型' }).optional(), + status: z.enum(PublishStatus, { message: '状态' }).optional(), + time: z.tuple([ + z.union([z.date(), z.string()]).transform(arg => new Date(arg)), + z.union([z.date(), z.string()]).transform(arg => new Date(arg)), + ]).optional(), +}) +export class PublishRecordListFilterDto extends createZodDto(PublishRecordListFilterSchema) {} + +export const PublishDayInfoListFiltersSchema = z.object({ + userId: z.string(), + time: z.tuple([ + z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + z.union([z.date(), z.string()]).transform((arg) => { + return new Date(arg) + }), + ]).optional(), +}) +export class PublishDayInfoListFiltersDto extends createZodDto(PublishDayInfoListFiltersSchema) {} + +export const PublishDayInfoListSchema = z.object({ + filters: PublishDayInfoListFiltersSchema, + page: z.object({ + pageNo: z.number().min(1, { message: '页码不能小于1' }), + pageSize: z.number().min(1, { message: '页大小不能小于1' }), + }), +}) +export class PublishDayInfoListDto extends createZodDto(PublishDayInfoListSchema) {} + +export const GetPublishRecordDetailSchema = z.object({ + flowId: z.string({ message: 'flowId is required' }), + userId: z.string({ message: 'userId is required' }), +}) + +export class GetPublishRecordDetailDto extends createZodDto(GetPublishRecordDetailSchema) {} + +export const donePublishRecordSchema = z.object({ + filter: z.object({ + dataId: z.string({ message: '数据ID' }), + uid: z.string({ message: '渠道ID' }), + }), + data: z.object({ + workLink: z.string({ message: '作品链接' }).optional(), + dataOption: z.any().optional(), + }), +}) +export class DonePublishRecordDto extends createZodDto(donePublishRecordSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.controller.ts new file mode 100644 index 000000000..52f2ceb77 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.controller.ts @@ -0,0 +1,105 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 发布 + */ +import { BadRequestException, Body, Controller, Post } from '@nestjs/common' +import { AccountType } from '@yikart/common' +import { CreatePublishRecordDto, DonePublishRecordDto, GetPublishRecordDetailDto, PublishDayInfoListDto, PublishRecordIdDto, PublishRecordListFilterDto } from './dto/publish.dto' +import { PublishRecordService } from './publishRecord.service' + +@Controller() +export class PublishRecordController { + constructor(private readonly publishRecordService: PublishRecordService) {} + + // 创建发布记录 + // @NatsMessagePattern('publish.publishRecord.create') + @Post('publish/publishRecord/create') + async createPublishRecord(@Body() data: CreatePublishRecordDto) { + const res = await this.publishRecordService.createPublishRecord(data) + return res + } + + // 删除发布记录 + // @NatsMessagePattern('publish.publishRecord.delete') + @Post('publish/publishRecord/delete') + async deletePublishRecord(@Body() data: PublishRecordIdDto) { + const res = await this.publishRecordService.deletePublishRecordById( + data.id, + ) + return res + } + + // 获取发布记录信息 + // @NatsMessagePattern('publish.publishRecord.info') + @Post('publish/publishRecord/info') + async getPublishRecordInfo(@Body() data: PublishRecordIdDto) { + const res = await this.publishRecordService.getPublishRecordInfo(data.id) + return res + } + + // 获取发布记录列表 + // @NatsMessagePattern('publish.publishRecord.list') + @Post('publish/publishRecord/list') + async getPublishRecordList(@Body() data: PublishRecordListFilterDto) { + const res = await this.publishRecordService.getPublishRecordList(data) + return res + } + + // @NatsMessagePattern('publish.publishInfo.data') + @Post('publish/publishInfo/data') + async getPublishInfoData(@Body() data: { userId: string }) { + const res = await this.publishRecordService.getPublishInfoData(data.userId) + return res || {} + } + + // @NatsMessagePattern('publish.publishRecord.infoByDataId') + @Post('publish/publishRecord/infoByDataId') + async getPublishRecordByDataId(@Body() data: { dataId: string, accountType: AccountType }) { + const res = await this.publishRecordService.getPublishRecordByDataId(data.accountType, data.dataId) + return res + } + + // @NatsMessagePattern('publish.PublishDayInfo.list') + @Post('publish/PublishDayInfo/list') + async getPublishDayInfoList(@Body() data: PublishDayInfoListDto) { + const res = await this.publishRecordService.getPublishDayInfoList(data.filters, data.page) + return res + } + + // @NatsMessagePattern('publish.publishRecord.detail') + @Post('publish/publishRecord/detail') + async getPublishRecordDetail(@Body() data: GetPublishRecordDetailDto) { + const res = await this.publishRecordService.getPublishRecordDetail(data) + if (!res) { + throw new BadRequestException('publish record not found') + } + return res + } + + // @NatsMessagePattern('publish.publishRecord.detail.byTaskId') + @Post('publish/publishRecord/detail/byTaskId') + async getPublishRecordDetailByTaskId(@Body() data: { taskId: string, userId: string }) { + const res = await this.publishRecordService.getPublishRecordByTaskId(data.taskId, data.userId) + if (!res) { + throw new BadRequestException('publish record not found') + } + return res + } + + // @NatsMessagePattern('channel.publishRecord.userTask') + @Post('channel/publishRecord/userTask') + async getPublishRecordToUserTask(@Body() data: { userTaskId: string }) { + const res = await this.publishRecordService.getPublishRecordToUserTask(data.userTaskId) + return res + } + + // @NatsMessagePattern('publish.publishRecord.done') + @Post('publish/publishRecord/done') + async donePublishRecord(@Body() data: DonePublishRecordDto) { + const res = await this.publishRecordService.donePublishRecord(data.filter, data.data) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.module.ts new file mode 100644 index 000000000..a04ef5dae --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common' +import { ContentModule } from '../content/content.module' +import { PublishRecordController } from './publishRecord.controller' +import { PublishRecordService } from './publishRecord.service' + +@Module({ + imports: [ + ContentModule, + ], + providers: [ + PublishRecordService, + ], + controllers: [PublishRecordController], + exports: [PublishRecordService], +}) +export class PublishModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.service.ts new file mode 100644 index 000000000..c9fc0a45f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/publishRecord/publishRecord.service.ts @@ -0,0 +1,269 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: PublishRecord + */ +import { Injectable } from '@nestjs/common' +import { EventEmitter2 } from '@nestjs/event-emitter' +import { AccountType, TableDto } from '@yikart/common' +import { PublishRecord, PublishRecordRepository, PublishStatus } from '@yikart/mongodb' +import dayjs from 'dayjs' +import { MaterialService } from '../content/material.service' +import { TaskNatsApi } from '../transports/task/api/task.natsApi' +import { UserTaskNatsApi } from '../transports/task/api/user-task.natsApi' +import { PointsService } from '../user/points.service' +import { + GetPublishRecordDetailDto, + PublishDayInfoListFiltersDto, + PublishRecordListFilterDto, +} from './dto/publish.dto' + +@Injectable() +export class PublishRecordService { + constructor( + private readonly publishRecordRepository: PublishRecordRepository, + private readonly materialService: MaterialService, + private readonly userTaskNatsApi: UserTaskNatsApi, + private readonly taskNatsApi: TaskNatsApi, + private eventEmitter: EventEmitter2, + private readonly pointsService: PointsService, + ) { } + + /** + * 创建 + * @param data + * @returns + */ + async createPublishRecord(data: Partial) { + const res = await this.publishRecordRepository.create(data) + if (data.dataId && data.accountType && data.uid && data.taskId) { + this.doTaskProcess(res) + } + this.upDayPublishInfo(res) + this.grantPublishReward(res) + return res + } + + /** + * 获取发布记录列表 + * @param query + * @returns + */ + async getPublishRecordList(query: PublishRecordListFilterDto): Promise { + const res = await this.publishRecordRepository.getPublishRecordList(query) + return res + } + + // 获取发布记录信息 + async getPublishRecordInfo(id: string) { + return this.publishRecordRepository.getPublishRecordInfo(id) + } + + // 删除发布记录 + async deletePublishRecordById(id: string): Promise { + const res = await this.publishRecordRepository.deletePublishRecordById(id) + return res + } + + // 更新 + async updatePublishRecord( + filter: any, + data: Partial, + ) { + const res = await this.publishRecordRepository.updatePublishRecord(filter, data) + return res + } + + /** + * task process + * @param data + * @returns + */ + private async doTaskProcess(data: Partial) { + if (!data.userTaskId || !data.userId) + return + + const userTask = await this.userTaskNatsApi.getUserTaskInfo( + data.userTaskId, + ) + + if (!userTask) + return + + data.taskId = userTask.taskId + // 触发任务追踪事件 + if (data.accountType && data.uid && data.dataId) { + this.eventEmitter.emit( + 'statistics.task.userTaskPosts', + { + accountId: data.accountId!, + taskId: data.taskId, + type: data.accountType, + uid: data.uid!, + postId: data.dataId, + }, + ) + } + const taskInfo = await this.taskNatsApi.getTaskInfo(userTask.taskId) + + const taskMaterialId = userTask.taskMaterialId || data.taskMaterialId + // 删除草稿 + if (taskInfo && taskMaterialId) { + if (taskInfo.autoDeleteMaterial) { + this.materialService.del(taskMaterialId) + } + else { + this.materialService.addUseCount(taskMaterialId) + } + } + } + + private addPoints(userId: string, amount: number) { + this.pointsService.addPoints({ + userId, + amount, + type: 'publish', + description: '发布奖励', + metadata: {}, + }) + } + + private getNeedPubPoints(days: number) { + return days <= 7 ? 1 : days <= 21 ? 2 : 3 + } + + /** + * change day publish info + * if data had publish record, update it + * @param data + */ + private async upDayPublishInfo(data: PublishRecord) { + await this.publishRecordRepository.upDayPublishInfo(data) + } + + /** + * 获取发布每日信息列表 + * @param inFilter + * @param pageInfo + * @returns + */ + async getPublishDayInfoList( + inFilter: PublishDayInfoListFiltersDto, + pageInfo: TableDto, + ) { + return this.publishRecordRepository.getPublishDayInfoList( + inFilter, + pageInfo, + ) + } + + // 发放发布奖励 + async grantPublishReward(data: PublishRecord) { + // 1. 查询发放状态 + const recordInfo = await this.publishRecordRepository.findUserRecordInfo(data.userId) + // 2. 第一次发布 + if (!recordInfo) { + this.addPoints(data.userId, 1) + this.publishRecordRepository.createPublishInfo({ + userId: data.userId, + upInfoDate: new Date(), + days: 1, + }) + return + } + + const { upInfoDate } = recordInfo + // 如果是今天,直接返回 + if (dayjs(upInfoDate).isSame(dayjs(), 'day')) { + return + } + // 3. 判断upInfoDate时间是否是昨天 + const isYesterday = dayjs(upInfoDate).isSame( + dayjs().subtract(1, 'day'), + 'day', + ) + // 3.1 不是昨天 + if (!isYesterday) { + this.addPoints(data.userId, 1) + await this.publishRecordRepository.updateUserPublishInfo( + data.userId, + { upInfoDate: new Date(), days: 1 }, + ) + return + } + + // 3.2 是昨天 + const { days } = recordInfo + this.addPoints(data.userId, this.getNeedPubPoints(days + 1)) + const newDays = days + 1 + await this.publishRecordRepository.updateUserPublishInfo( + data.userId, + { upInfoDate: new Date(), days: newDays }, + ) + } + + // 获取发布信息数据 + async getPublishInfoData(userId: string) { + const res = await this.publishRecordRepository.getPublishInfoData(userId) + return res + } + + // 根据获取发布记录信息 + async getPublishRecordByDataId(accountType: AccountType, dataId: string) { + const res = await this.publishRecordRepository.getPublishRecordByDataId(accountType, dataId) + return res + } + + async getPublishRecordDetail(data: GetPublishRecordDetailDto) { + const publishRecord = await this.publishRecordRepository.getPublishRecordDetail({ + flowId: data.flowId, + userId: data.userId, + }) + return publishRecord + } + + async getPublishRecordByTaskId(taskId: string, userId: string) { + const res = await this.publishRecordRepository.getPublishRecordByTaskId(taskId, userId) + return res + } + + async getPublishRecordByDataIdAndUid(uid: string, dataId: string) { + const res = await this.publishRecordRepository.getPublishRecordByDataIdAndUid(uid, dataId) + return res + } + + /** + * 根据用户任务ID获取发布记录 + * @param userTaskId + * @returns + */ + async getPublishRecordToUserTask(userTaskId: string) { + const res = await this.publishRecordRepository.getPublishRecordToUserTask(userTaskId) + return res + } + + // 完成发布 + async donePublishRecord( + filter: { dataId: string, uid: string }, + data: { + workLink?: string + dataOption?: unknown + }, + ): Promise { + const res = await this.publishRecordRepository.donePublishRecord(filter, data) + if (!res) + return false + this.doTaskProcess(res) + return !!res + } + + async updatePublishRecordStatus(id: string, status: PublishStatus, errorMsg?: string) { + const res = await this.publishRecordRepository.updatePublishRecord( + { _id: id }, + { status, errorMsg }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.controller.ts new file mode 100644 index 000000000..4cd9c5253 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.controller.ts @@ -0,0 +1,123 @@ +import { Body, Controller, Get, Post } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { AccountDataService } from './accountData.service' +import { + GetAccountDataByParamsDto, + GetAccountDataLatestDto, + GetAccountDataPeriodDto, + GetAuthorDataByDateDto, + GetChannelDataLatestByUidsDto, + GetChannelDataPeriodByUidsDto, + NewChannelDto, +} from './dto/accountData.dto' + +@ApiTags('统计') +@Controller('statistics') +export class AccountDataController { + constructor(private readonly accountDataService: AccountDataService) {} + + // 健康检查端点 + @Get('health') + async healthCheck() { + try { + const state = await this.accountDataService.getConnectionState() + return { + status: 'ok', + database: state === 1 ? 'connected' : 'disconnected', + timestamp: new Date().toISOString(), + } + } + catch (error: any) { + return { + status: 'error', + message: error.message, + timestamp: new Date().toISOString(), + } + } + } + + // 调试端点:获取集合信息 + @Get('debug/collection') + async getCollectionInfo() { + try { + const info = await this.accountDataService.getCollectionInfo('bilibili') + return { + status: 'ok', + info, + timestamp: new Date().toISOString(), + } + } + catch (error: any) { + return { + status: 'error', + message: error.message, + timestamp: new Date().toISOString(), + } + } + } + + // 根据账号和日期查询频道数据 + // @NatsMessagePattern('statistics.account.getAuthorDataByDate') + @Post('author/getAuthorDataByDate') + getAuthorDataByDate(@Body() data: GetAuthorDataByDateDto) { + const res = this.accountDataService.getAuthorDataByDate(data.accountId, data.platform, data.date) + return res + } + + // 根据账号查询频道最新数据 + // @NatsMessagePattern('statistics.account.getAccountDataLatest') + @Post('account/latest') + AuthorDataLatest(@Body() data: GetAccountDataLatestDto) { + const res = this.accountDataService.getAccountDataLatest(data.accountId, data.platform, data.uid) + return res + } + + // 根据账号查询频道最新增量数据 + // @NatsMessagePattern('statistics.account.getAccountDataIncrease') + @Post('account/increase') + AccountDataIncrease(@Body() data: GetAccountDataLatestDto) { + const res = this.accountDataService.getAccountDataIncrease(data.platform, data.uid) + return res + } + + // 根据查询条件筛选频道 + // @NatsMessagePattern('statistics.account.getAccountDataByParams') + @Post('account/getAccountDataByParams') + AccountDataByParams(@Body() data: GetAccountDataByParamsDto) { + const res = this.accountDataService.getAccountDataByParams(data.params, data.sort, data.pageNo, data.pageSize) + return res + } + + // 根据账号查询频道一段时间数据 + // @NatsMessagePattern('statistics.account.getAccountDataPeriod') + @Post('account/period') + AccountDataPeriod(@Body() data: GetAccountDataPeriodDto) { + const res = this.accountDataService.getAccountDataPeriod(data.accountId, data.platform, data.uid, data.startDate, data.endDate) + return res + } + + // 根据platform和uid数组查询频道最新数据并汇总fansCount + // @NatsMessagePattern('statistics.account.getChannelDataLatestByUids') + @ApiOperation({ summary: '批量获取频道最新数据并汇总粉丝数' }) + @Post('channels/latest-batch') + getChannelDataLatestByUids(@Body() data: GetChannelDataLatestByUidsDto) { + const res = this.accountDataService.getChannelDataLatestByUids(data.queries) + return res + } + + // 根据platform和uid数组查询频道一段时间增量数据 + // @NatsMessagePattern('statistics.account.getChannelDataPeriodByUids') + @ApiOperation({ summary: '批量获取频道一段时间数据' }) + @Post('channels/period-batch') + getChannelDataPeriodByUids(@Body() data: GetChannelDataPeriodByUidsDto) { + return this.accountDataService.getChannelDataPeriodByUids(data.queries, data?.startDate, data?.endDate) + } + + // 存储新增channel + // @NatsMessagePattern('statistics.channel.newChannelReport') + @Post('channels/newChannelReport') + setNewChannelReport(@Body() data: NewChannelDto) { + const res = this.accountDataService.setNewChannels(data.platform, data.uid) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.module.ts new file mode 100644 index 000000000..1f8291f6b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common' +import { AccountDataController } from './accountData.controller' +import { AccountDataService } from './accountData.service' + +@Module({ + imports: [], + controllers: [AccountDataController], + providers: [AccountDataService], +}) +export class AccountDataModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.service.ts new file mode 100644 index 000000000..8178aa1c4 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/accountData.service.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common' +import { AccountDataRepository } from '@yikart/statistics-db' +import { getDayRangeUTC } from '../../common/utils' + +@Injectable() +export class AccountDataService { + constructor( + private readonly accountDataRepository: AccountDataRepository, + ) { + } + + /** + * 获取数据库连接状态 + */ + async getConnectionState(): Promise { + return this.accountDataRepository.getConnectionState() + } + + /** + * 调试方法:获取集合信息 + */ + async getCollectionInfo(platform: string) { + return this.accountDataRepository.getCollectionInfo(platform) + } + + /** + * 根据账号和日期查询作者数据 + */ + async getAuthorDataByDate(accountId: string, platform: string, date: string) { + const dataTime = getDayRangeUTC(date) + return this.accountDataRepository.getAuthorDataByDate(accountId, platform, [dataTime.start, dataTime.end]) + } + + /** + * 根据账号查询频道最新数据 + */ + async getAccountDataLatest(accountId: string, platform: string, uid: string) { + return this.accountDataRepository.getAccountDataLatest(accountId, platform, uid) + } + + /** + * 根据账号查询频道最新增量数据 + */ + async getAccountDataIncrease(platform: string, uid: string) { + return this.accountDataRepository.getAccountDataIncrease(platform, uid) + } + + /** + * 根据账号查询作品最新增量数据 + */ + async getPostDataIncrease(platform: string, uid: string) { + return this.accountDataRepository.getPostDataIncrease(platform, uid) + } + + /** + * 根据查询条件筛选账号 + */ + async getAccountDataByParams(params: any, sort: string, pageNo: number, pageSize: number) { + return this.accountDataRepository.getAccountDataByParams(params, sort, pageNo, pageSize) + } + + /** + * 根据账号查询频道一段时间数据 + */ + async getAccountDataPeriod(accountId: string, platform: string, uid: string, startDate: string, endDate: string) { + return this.accountDataRepository.getAccountDataPeriod(accountId, platform, uid, startDate, endDate) + } + + /** + * 根据platform和uid数组查询频道最新数据并汇总fansCount + * @param queries 包含platform和uid组合的数组 + * @returns 汇总后的fansCount总数和查询到的所有数据 + */ + async getChannelDataLatestByUids(queries: Array<{ platform: string, uid: string }>) { + return this.accountDataRepository.getChannelDataLatestByUids(queries) + } + + /** + * 根据platform和uid数组查询频道一段时间增量数据 + * @param queries 包含platform和uid组合的数组 + * @param startDate 可选,格式示例 '2025-09-01'(默认 7 天前) + * @param endDate 可选,格式示例 '2025-09-04'(默认 昨天) + * @returns 查询到的所有增量数据 + */ + async getChannelDataPeriodByUids( + queries: Array<{ platform: string, uid: string }>, + startDate?: string, + endDate?: string, + ) { + return this.accountDataRepository.getChannelDataPeriodByUids(queries, startDate, endDate) + } + + /** + * 根据platform和uid数组查询频道最新增量 + * @param queries 包含platform和uid组合的数组 + * @returns 返回dailyDelta字段的汇总数据 + */ + async getChannelDeltaByUids(queries: Array<{ platform: string, uid: string }>) { + return this.accountDataRepository.getChannelDeltaByUids(queries) + } + + /** + * 新增账号推送 + */ + async setNewChannels(platform: string, uid: string) { + return this.accountDataRepository.setNewChannels(platform, uid) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/dto/accountData.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/dto/accountData.dto.ts new file mode 100644 index 000000000..c1317cb25 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/accountData/dto/accountData.dto.ts @@ -0,0 +1,116 @@ +import { Expose, Type } from 'class-transformer' +import { IsOptional, IsString, ValidateNested } from 'class-validator' + +export class AccountIdDto { + @IsString({ message: '账号ID' }) + @Expose() + readonly accountId: string +} + +export class UserIdDto { + @IsString({ message: '用户ID' }) + @Expose() + readonly userId: string +} + +export class GetAuthorDataByDateDto extends AccountIdDto { + @IsString({ message: '平台' }) + @Expose() + readonly platform: string + + @IsString({ message: '日期' }) + @Expose() + readonly date: string +} + +export class GetAccountDataLatestDto extends AccountIdDto { + @IsString({ message: 'platform' }) + @Expose() + readonly platform: string + + @IsString({ message: 'uid' }) + @Expose() + readonly uid: string +} + +export class GetAccountDataByParamsDto { + @IsOptional() + @Expose() + readonly params: any + + @IsOptional() + @Expose() + readonly sort: any + + @IsOptional() + @Expose() + readonly pageNo: number + + @IsOptional() + @Expose() + readonly pageSize: number +} + +export class GetAccountDataPeriodDto extends AccountIdDto { + @IsString({ message: '平台' }) + @Expose() + readonly platform: string + + @IsString({ message: 'uid' }) + @Expose() + readonly uid: string + + @IsString({ message: '开始日期' }) + // @IsOptional() + @Expose() + readonly startDate: string + + @IsString({ message: '结束日期' }) + // @IsOptional() + @Expose() + readonly endDate: string +} + +export class GetChannelDataLatestByUidsDto { + @ValidateNested({ each: true }) + @Type(() => PlatformUidQueryDto) + @Expose() + readonly queries: PlatformUidQueryDto[] +} + +export class PlatformUidQueryDto { + @IsString({ message: '平台' }) + @Expose() + readonly platform: string + + @IsString({ message: 'uid' }) + @Expose() + readonly uid: string +} + +export class GetChannelDataPeriodByUidsDto { + @ValidateNested({ each: true }) + @Type(() => PlatformUidQueryDto) + @Expose() + readonly queries: PlatformUidQueryDto[] + + @IsString({ message: '开始日期' }) + @IsOptional() + @Expose() + readonly startDate?: string + + @IsString({ message: '结束日期' }) + @IsOptional() + @Expose() + readonly endDate?: string +} + +export class NewChannelDto { + @IsString({ message: '平台' }) + @Expose() + readonly platform: string + + @IsString({ message: 'uid' }) + @Expose() + readonly uid: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.controller.ts new file mode 100644 index 000000000..f82d8633c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.controller.ts @@ -0,0 +1,66 @@ +import { Body, Controller, Logger, Post } from '@nestjs/common' +import { ApiOperation } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { ChannelService } from './channel.service' +import { BatchHistoryPostsRecordDto, searchTopicDto, SubmitChannelCrawlingDto } from './dto/channel.dto' + +@Controller('statistics/channels') +export class ChannelController { + private readonly logger = new Logger(ChannelController.name) + constructor( + private readonly channelService: ChannelService, + ) {} + + /** + * Douyin search topic + * @param token + * @param data + * @returns + */ + @ApiOperation({ + summary: 'Topic search', + description: 'Search topics on Douyin/TikTok', + }) + @Public() + @Post('douyin/searchTopic') + async douYinSerachTopic( + @Body() data: searchTopicDto, + ) { + return this.channelService.getDouyinTopic(data.topic, data?.language) + } + + /** + * User selects history publish records and sends them to draft (batch) + * @param data + * @returns + */ + @Post('posts/postsRecord') + async setHistoryPostsRecord(@Body() data: BatchHistoryPostsRecordDto) { + return this.channelService.historyPostsRecord(data.records) + } + + /** + * Query history records added-to-draft status + * @param token + * @returns + */ + @Post('posts/recordStatus') + async getHistoryPostsRecordStatus(@GetToken() token: TokenInfo) { + return this.channelService.historyPostsRecordStatus(token.id) + } + + /** + * Submit channel for crawling + * @param data + * @returns + */ + @ApiOperation({ + summary: 'Submit channel for crawling', + description: 'Submit platform and uid to crawling queue; updateAt is set automatically', + }) + @Public() + @Post('crawling/submit') + async submitChannelCrawling(@Body() data: SubmitChannelCrawlingDto) { + return this.channelService.submitChannelCrawling(data.platform, data.uid) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.module.ts new file mode 100644 index 000000000..876c47475 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { RedisModule } from '@yikart/redis' +import { ChannelController } from './channel.controller' +import { ChannelService } from './channel.service' + +@Module({ + imports: [RedisModule], + controllers: [ChannelController], + providers: [ChannelService], +}) +export class ChannelModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.service.ts new file mode 100644 index 000000000..35f96b863 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/channel.service.ts @@ -0,0 +1,185 @@ +import { Injectable, Logger } from '@nestjs/common' +import { RedisService } from '@yikart/redis' +import { AccountType, ChannelRepository, JobTaskStatus, PostRepository } from '@yikart/statistics-db' +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import { config } from '../../config' + +interface HistoryPostsRecordItem { + userId: string + platform: AccountType + uid: string + postId: string + accountId?: string + title?: string + desc?: string + cover?: string + publishTime?: Date + mediaType?: string + url?: string + viewCount?: number + commentCount?: number + likeCount?: number + shareCount?: number + favoriteCount?: number +} + +@Injectable() +export class ChannelService { + private readonly logger = new Logger(ChannelService.name) + + constructor( + private readonly channelRepository: ChannelRepository, + private readonly postRepository: PostRepository, + private readonly redisService: RedisService, + ) {} + + /** + * get platform cookie + * @param platform + */ + async getChannelCookie(platform: string) { + const channelCookie = await this.channelRepository.getChannelCookieByPlatform(platform) + this.logger.log(`get cookie from db: ${channelCookie?._id}`) + const res = channelCookie?.res + const chosen = Array.isArray(res) && res.length > 0 + ? res[Math.floor(Math.random() * res.length)] + : { cookie: '' } + return chosen + } + + /** + * Topic search + * @param topic + * @param language + */ + async getDouyinTopic(topic: string, language: string) { + this.logger.log(`search topic:-- ${topic}, language: ${language}`) + if (language === 'zh-CN' || language === 'CN' || language === 'zh-Hans') { + const cookie = await this.getChannelCookie('douyin') + const url = `${config.moreApi.platApiUri}/api/douyin/search_topic` + this.logger.log(`${url}`) + const headerParams: AxiosRequestConfig = { + headers: { + Cookie: `${cookie.cookie}`, + }, + } + const params = { + keyword: topic, + count: 10, + cursor: '0', + search_id: '', + proxy: '', + } + const response: AxiosResponse = await axios.post(url, params, headerParams) + // Extract cha_name array from response + const names = response?.data?.data?.challenge_list + ?.map((item: any) => item?.challenge_info?.cha_name) + ?.filter((v: any) => !!v) ?? [] + return names + } + else { + const cookie = await this.getChannelCookie('tiktok') + const url = `https://www.tiktok.com/api/upload/challenge/sug/?keyword=${topic}&app_language=en` + this.logger.log(`${url}`) + const headerParams: AxiosRequestConfig = { + headers: { + Cookie: `${cookie.cookie}`, + }, + } + const response: AxiosResponse = await axios.get(url, headerParams) + const names = response?.data.sug_list + ?.map((item: any) => item?.cha_name) + ?.filter((v: any) => !!v) ?? [] + return names + } + } + + /** + * User selects history publish records and sends them to draft + * @param records history records array + */ + async historyPostsRecord(records: HistoryPostsRecordItem[]) { + // Batch fetch details for each record + const detailPromises = records.map(async (record) => { + try { + const postDetail = await this.postRepository.getPostsByPid({ + platform: record.platform, + postId: record.postId, + }) + + // Check if valid data is returned + const hasDetail = postDetail && Object.keys(postDetail).length > 0 + const detail = postDetail as any // Type assertion for possibly empty result + + // Merge fetched details with original data + return { + ...record, + // Override original with details if present + title: hasDetail && detail.title ? detail.title : (record.title || ''), + desc: hasDetail && detail.desc ? detail.desc : (record.desc || ''), + cover: hasDetail && detail.cover ? detail.cover : (record.cover || ''), + publishTime: hasDetail && detail.publishTime ? new Date(detail.publishTime) : (record.publishTime || new Date()), + // Additional fields from detail + mediaType: hasDetail ? detail.mediaType : '', + url: hasDetail ? detail.url : '', + viewCount: hasDetail ? detail.readCount : 0, + commentCount: hasDetail ? detail.commentCount : 0, + likeCount: hasDetail ? detail.likeCount : 0, + shareCount: hasDetail ? detail.forwardCount : 0, + favoriteCount: hasDetail ? detail.collectCount : 0, + } + } + catch (error) { + this.logger.warn(`Failed to get detail for post ${record.postId}: ${(error as Error).message}`) + // Fallback to original record on error + return record + } + }) + + // Wait for all detail queries to complete + const mergedRecords = await Promise.all(detailPromises) + + // Pass merged data to subsequent processing + return await this.channelRepository.historyPostsRecord(mergedRecords) + } + + /** + * query history add to draft status + * @param userId + */ + async historyPostsRecordStatus(userId: string) { + return await this.channelRepository.historyPostsRecordStatus(userId) + } + + /** + * Average data for the last month + * @param platform + * @param uid + */ + async averageDataMonthly(platform: AccountType, uid: string) { + return await this.channelRepository.averageDataMonthly(platform, uid) + } + + /** + * Submit channel for crawling + * @param platform platform type + * @param uid channel uid + */ + async submitChannelCrawling(platform: AccountType, uid: string) { + try { + const result = await this.channelRepository.submitChannelCrawling({ + platform, + uid, + createAt: new Date(), + updateAt: new Date(), + status: JobTaskStatus.Pending, + }) + this.logger.log(`Submitted channel for crawling: platform=${platform}, uid=${uid}`) + return result + } + catch (error) { + this.logger.error(`Failed to submit channel for crawling: ${(error as Error).message}`) + throw error + } + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/dto/channel.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/dto/channel.dto.ts new file mode 100644 index 000000000..242811a5a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/channel/dto/channel.dto.ts @@ -0,0 +1,44 @@ +import { createZodDto } from '@yikart/common' +import { AccountType } from '@yikart/statistics-db' +import { z } from 'zod' + +export const accountIdSchema = z.object({ + accountId: z.string().min(1, { message: 'accountId is required' }), +}) + +export class AccountIdDto extends createZodDto(accountIdSchema) {} + +export const searchTopic = z.object({ + topic: z.string().min(1, { message: 'topic is required' }), + language: z.string().optional().default('zh-CN').describe('language'), +}) +export class searchTopicDto extends createZodDto(searchTopic) {} + +export const SetHistoryPostsRecordSchema = z.object({ + accountId: z.string().optional().default('').describe('accountId'), + platform: z.enum(AccountType).describe('Platform'), + userId: z.string().min(1, { message: 'userId is required' }).describe('User ID from account table'), + uid: z.string().min(1, { message: 'uid is required' }).describe('UID from account table'), + postId: z.string().min(1, { message: 'postId is required' }).describe('Post ID'), +}) + +export class HistoryPostsRecordDto extends createZodDto(SetHistoryPostsRecordSchema) { } + +export const BatchHistoryPostsRecordSchema = z.object({ + records: z.array(SetHistoryPostsRecordSchema).describe('Array of history posts records'), +}) + +export class BatchHistoryPostsRecordDto extends createZodDto(BatchHistoryPostsRecordSchema) {} + +export const userIdSchema = z.object({ + userId: z.string().min(1, { message: 'userId is required' }), +}) + +export class UserIdDto extends createZodDto(userIdSchema) {} + +export const SubmitChannelCrawlingSchema = z.object({ + platform: z.enum(AccountType).describe('Platform type'), + uid: z.string().min(1, { message: 'uid is required' }).describe('Channel UID'), +}) + +export class SubmitChannelCrawlingDto extends createZodDto(SubmitChannelCrawlingSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/common.ts new file mode 100644 index 000000000..8d721f983 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/common.ts @@ -0,0 +1,10 @@ +import { AccountType } from '@yikart/common' + +export interface NewAccountCrawlerData { + accountId?: string + userId?: string + platform: AccountType + uid: string // 频道平台唯一ID + avatar?: string + nickname?: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.controller.ts new file mode 100644 index 000000000..91ee82411 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' +import { Public } from '@yikart/aitoearn-auth' +import { FetchAllPostsRequestDto, FetchPostRequestDto, FetchPostsRequestDto } from './post.dto' +import { PostService } from './post.service' + +@ApiTags('社交媒体作品') +@Controller('statistics/posts') +export class PostController { + constructor( + private readonly postService: PostService, + ) {} + + // 测试路由 + @Public() + @Post('test') + async test() { + return { message: 'PostController is working!' } + } + + @Post('list') + async fetchChannelPosts(@Body() payload: FetchPostsRequestDto) { + return await this.postService.getPostsByPlatform(payload) + } + + @Post('detail') + async fetchOnePostDetail(@Body() payload: FetchPostRequestDto) { + return await this.postService.getPostsByPid(payload) + } + + @Post('withoutPagination') + async fetchChannelAllPosts(@Body() payload: FetchAllPostsRequestDto) { + return await this.postService.getUserAllPostsByPlatform(payload) + } + + /** + * 各数据字段计算平均数据,日期可选 + * @param payload + * @returns + */ + @Post('average') + async userAverageSummaryMonthly(@Body() payload: FetchAllPostsRequestDto) { + return this.postService.getAverageSummaryMonthly(payload) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.dto.ts new file mode 100644 index 000000000..4d2415c13 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.dto.ts @@ -0,0 +1,40 @@ +import { createZodDto } from '@yikart/common' +import { AccountType } from '@yikart/statistics-db' +import z from 'zod' + +export const FetchPostsRequestSchema = z.object({ + platform: z.enum(AccountType).describe('平台'), + uid: z.string().describe('userId, account表中的uid字段'), + page: z.number().min(1).default(1).describe('页码,默认1'), + pageSize: z.number().min(1).max(100).default(20).describe('每页数量,默认20,最大100'), +}) + +export class FetchPostsRequestDto extends createZodDto(FetchPostsRequestSchema) { } + +export const FetchPostsBatchRequestSchema = z.object({ + platform: z.enum(AccountType).describe('平台'), + postIds: z.array(z.string()).describe('作品 postId 数组'), + page: z.number().min(1).default(1).describe('页码,默认1'), + pageSize: z.number().min(1).max(100).default(20).describe('每页数量,默认20,最大100'), +}) + +export class FetchPostsBatchRequestDto extends createZodDto(FetchPostsBatchRequestSchema) { } + +export const FetchPostRequestSchema = z.object({ + platform: z.enum(AccountType).describe('平台'), + postId: z.string().describe('作品 postId'), +}) + +export class FetchPostRequestDto extends createZodDto(FetchPostRequestSchema) { } + +export const FetchAllPostsRequestSchema = z.object({ + platform: z.enum(AccountType).describe('平台').optional(), + userId: z.string().describe('userId, account表中的userId字段'), + uid: z.string().optional().describe('userId, account表中的uid字段'), + range: z.object({ + start: z.string().describe('开始时间,ISO格式'), + end: z.string().describe('结束时间,ISO格式'), + }).optional().describe('数据查询时间范围,默认查询所有'), +}) + +export class FetchAllPostsRequestDto extends createZodDto(FetchAllPostsRequestSchema) { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.module.ts new file mode 100644 index 000000000..ac6dbbf4c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PostController } from './post.controller' +import { PostService } from './post.service' + +@Module({ + imports: [], + controllers: [PostController], + providers: [PostService], + exports: [PostService], +}) +export class PostModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.service.ts new file mode 100644 index 000000000..c73903afa --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from '@nestjs/common' +import { PostRepository } from '@yikart/statistics-db' +import { FetchAllPostsRequestDto, FetchPostRequestDto, FetchPostsBatchRequestDto, FetchPostsRequestDto } from './post.dto' + +@Injectable() +export class PostService { + private readonly logger = new Logger(PostService.name) + constructor( + private readonly postRepository: PostRepository, + ) { + } + + // 根据平台platform和uid 获取作品 + async getPostsByPlatform( + payload: FetchPostsRequestDto, + ) { + return await this.postRepository.getPostsByPlatform(payload) + } + + // 根据平台platform和作品id数组postIds 获取作品 + async getPostsByPids( + payload: FetchPostsBatchRequestDto, + ) { + return await this.postRepository.getPostsByPids(payload) + } + + // 根据平台platform和作品postId 获取单个作品数据 + async getPostsByPid( + payload: FetchPostRequestDto, + ) { + return await this.postRepository.getPostsByPid(payload) + } + + /** + * 根据平台、postId 与时间范围查询帖子数据 + * - startDate 为空时,默认取 endDate 往前 90 天 + * - endDate 为空时,默认取今天 + * - 时间字段使用 publishTime(number 时间戳) + */ + async getPostDataByDateRange(payload: { + platform: string + postId: string + startDate?: string | number | Date + endDate?: string | number | Date + page?: number + pageSize?: number + }) { + return await this.postRepository.getPostDataByDateRange(payload) + } + + async getUserAllPosts( + payload: FetchAllPostsRequestDto, + ) { + return await this.postRepository.getUserAllPosts(payload) + } + + async getUserAllPostsByPlatform( + payload: FetchAllPostsRequestDto, + ) { + return await this.postRepository.getUserAllPostsByPlatform(payload) + } + + /** + * 计算查询到的作品列表中核心指标的平均值 + * - 输入范围由 payload 决定(如有传入 range 则按范围过滤) + * - 平均字段:viewCount、commentCount、likeCount、shareCount + * - 当无数据时,平均值返回 0 + */ + async getAverageSummaryMonthly(payload: FetchAllPostsRequestDto) { + return await this.postRepository.getAverageSummaryMonthly(payload) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.vo.ts new file mode 100644 index 000000000..2ddf2ad6c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/post/post.vo.ts @@ -0,0 +1,30 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const PostSchema = z.object({ + postId: z.string().describe('帖子ID'), + platform: z.string().describe('平台'), + title: z.string().nullable().describe('标题'), + content: z.string().nullable().describe('内容'), + thumbnail: z.string().nullable().describe('封面/缩略图链接'), + mediaType: z.enum(['video', 'image', 'article']).describe('媒体类型 video | image | article'), + permaLink: z.string().nullable().describe('作品外部链接'), + publishTime: z.number().describe('发布时间,时间戳'), + viewCount: z.number().describe('浏览数'), + commentCount: z.number().describe('评论数'), + likeCount: z.number().describe('点赞数'), + shareCount: z.number().describe('分享数'), + clickCount: z.number().describe('点击数'), + impressionCount: z.number().describe('曝光数'), + favoriteCount: z.number().describe('收藏数'), + updatedAt: z.date().describe('更新时间'), +}).describe('帖子数据') + +export const FetchPostsResponseSchema = z.object({ + total: z.number().describe('总数'), + posts: z.array(PostSchema).describe('帖子列表'), + hasMore: z.boolean().describe('是否有更多数据, 当值为true时再请求下一页'), +}) + +export class PostVo extends createZodDto(PostSchema) { } +export class FetchPostsResponseVo extends createZodDto(FetchPostsResponseSchema) { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.controller.ts new file mode 100644 index 000000000..d3ec9cb56 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.controller.ts @@ -0,0 +1,7 @@ +import { Controller } from '@nestjs/common' +import { ApiTags } from '@nestjs/swagger' + +@ApiTags('数据统计') +@Controller('statistics') +export class StatisticsController { +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.module.ts new file mode 100644 index 000000000..c1ac0536f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.module.ts @@ -0,0 +1,26 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { StatisticsDbModule } from '@yikart/statistics-db' +import { config } from '../config' +import { AccountDataModule } from './accountData/accountData.module' +import { ChannelModule } from './channel/channel.module' +import { PostModule } from './post/post.module' +import { StatisticsController } from './statistics.controller' +import { StatisticsService } from './statistics.service' +import { TaskModule } from './task/task.module' + +@Global() +@Module({ + imports: [ + HttpModule, + StatisticsDbModule.forRoot(config.statisticsDb), + ChannelModule, + AccountDataModule, + PostModule, + TaskModule, + ], + providers: [StatisticsService], + controllers: [StatisticsController], + exports: [StatisticsService], +}) +export class StatisticsModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.service.ts new file mode 100644 index 000000000..5f5b56390 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/statistics.service.ts @@ -0,0 +1,34 @@ +import { HttpService } from '@nestjs/axios' +import { Injectable } from '@nestjs/common' +import { AccountStatus } from '@yikart/mongodb' +import axios from 'axios' +import { NewAccountCrawlerData } from './common' + +@Injectable() +export class StatisticsService { + constructor( + private readonly httpService: HttpService, + ) { } + + /** + * TODO: 新频道的上报 + * @param data + */ + async NewChannelReport( + data: NewAccountCrawlerData, + ) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/account/portrait/report', + data, + ) + return res.data + } + + async updateStatisticsAccountStatus(userId: string, status: AccountStatus) { + const res = await axios.post( + 'http://127.0.0.1:3000/api/account/portrait/report', + { userId, status }, + ) + return res.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/dto/task.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/dto/task.dto.ts new file mode 100644 index 000000000..a0336fee6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/dto/task.dto.ts @@ -0,0 +1,45 @@ +import { createZodDto } from '@yikart/common' +import { AccountType } from '@yikart/statistics-db' +import { z } from 'zod' + +export const accountIdSchema = z.object({ + accountId: z.string().min(1, { message: 'accountId is required' }), +}) + +export class AccountIdDto extends createZodDto(accountIdSchema) {} + +export const taskIdSchema = z.object({ + taskId: z.string().min(1, { message: 'taskId is required' }), +}) +export class taskIdDto extends createZodDto(taskIdSchema) {} + +export const taskPostSchema = accountIdSchema + .extend({ + taskId: z.string().min(1, { message: 'taskId is required' }), + type: z.enum(AccountType), + uid: z.string().min(1), + postId: z.string().min(1), + }) + +export class taskPostDto extends createZodDto(taskPostSchema) {} + +export const taskPostsBatchSchema = accountIdSchema + .extend({ + taskId: z.string().min(1, { message: 'taskId is required' }), + platform: z.enum(AccountType), + uid: z.string().min(1), + postId: z.string().min(1), + }) + +export class taskPostsBatchDto extends createZodDto(taskPostsBatchSchema) {} + +export const postIdSchema = z.object({ + postId: z.string().min(1, { message: 'postId is required' }), +}) +export class postIdDto extends createZodDto(postIdSchema) {} + +export const postDetailSchema = z.object({ + postId: z.string().min(1, { message: 'postId is required' }), + platform: z.enum(AccountType), +}) +export class postDetailDto extends createZodDto(postDetailSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.controller.ts new file mode 100644 index 000000000..2ea6324a5 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.controller.ts @@ -0,0 +1,45 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { PostService } from '../post/post.service' +import { postDetailDto, taskIdDto, taskPostDto } from './dto/task.dto' +import { TaskService } from './task.service' + +@Controller('statistics/task') +export class TaskController { + constructor( + private readonly taskService: TaskService, + private readonly postService: PostService, + ) {} + + /** + * 用户任务提交作品记录 + * @param data + * @returns + */ + // @NatsMessagePattern('statistics.task.posts.record') + @Post('posts/record') + async getAccounts(@Body() data: taskPostDto) { + return this.taskService.userTaskPosts(data) + } + + /** + * 根据任务ID 获取作品数据 并汇总 + * @param data + * @returns + */ + // @NatsMessagePattern('statistics.task.posts.dataCube') + @Post('posts/dataCube') + async getPostsStatistics(@Body() data: taskIdDto) { + return await this.taskService.getTaskPostsSummary(data.taskId) + } + + /** + * 根据作品ID 按日期时间段 获取作品数据数组 + * @param data + * @returns + */ + // @NatsMessagePattern('statistics.task.posts.periodDetail') + @Post('posts/periodDetail') + async getPostsStatisticsDetail(@Body() data: postDetailDto) { + return await this.postService.getPostDataByDateRange({ platform: data.platform, postId: data.postId, page: 1, pageSize: 90 }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.module.ts new file mode 100644 index 000000000..1eb08b9ba --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common' +import { PostModule } from '../post/post.module' +import { TaskController } from './task.controller' +import { TaskService } from './task.service' + +@Module({ + imports: [PostModule], + controllers: [TaskController], + providers: [TaskService], +}) +export class TaskModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.service.ts new file mode 100644 index 000000000..f0aa8a843 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/statistics/task/task.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { AccountType, TaskRepository } from '@yikart/statistics-db' + +@Injectable() +export class TaskService { + constructor( + private readonly taskRepository: TaskRepository, + ) { } + + // 用户任务作品记录 + @OnEvent('statistics.task.userTaskPosts') + async userTaskPosts(data: { accountId: string, type: AccountType, uid: string, taskId: string, postId: string }) { + return this.taskRepository.userTaskPosts(data.accountId, data.type, data.uid, data.taskId, data.postId) + } + + // 根据taskId 查询 作品信息 + async getTaskPostsByTaskId(taskId: string) { + return this.taskRepository.getTaskPostsByTaskId(taskId) + } + + /** + * 根据任务ID获取作品数据并汇总统计 + * @param taskId 任务ID + * @returns 汇总统计结果 + */ + async getTaskPostsSummary(taskId: string) { + return this.taskRepository.getTaskPostsSummary(taskId) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/task/task.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/task/task.module.ts new file mode 100644 index 000000000..1c0b31217 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/task/task.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common' + +@Module({ + imports: [], + controllers: [], + providers: [], + exports: [], +}) +export class TaskModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/api.ts new file mode 100644 index 000000000..7e3f275ac --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/api.ts @@ -0,0 +1,342 @@ +export const NatsApi = { + manager: { + manager: { + createByAccount: 'manager.manager.createByAccount', + getInfoByAccount: 'manager.manager.getInfoByAccount', + }, + }, + user: { + admin: { + user: { + list: 'user.admin.user.list', + info: 'user.admin.user.info', + upPortrait: 'user.admin.user.upPortrait', + }, + vip: { + addAllPoints: 'user.admin.vip.addAllPoints', + }, + }, + vip: { + set: 'user.vip.set', + clear: 'user.vip.clear', + }, + income: { + deduct: 'user.income.deduct', + info: 'user.income.info', + }, + userWalletAccount: { + create: 'user.userWalletAccount.create', + delete: 'user.userWalletAccount.delete', + update: 'user.userWalletAccount.update', + info: 'user.userWalletAccount.info', + list: 'user.userWalletAccount.list', + }, + }, + account: { + account: { + getUserAccounts: 'account.account.getUserAccounts', + getAccounts: 'account.account.getAccounts', + getAccountInfoById: 'account.account.getAccountInfoById', + updateAccountInfo: 'account.account.updateAccountInfo', + updateAccountStatistics: 'account.account.updateAccountStatistics', + updateAccountStatus: 'account.account.updateAccountStatus', + getAccountListByIds: 'account.account.getAccountListByIds', + getUserAccountCount: 'account.account.getUserAccountCount', + deleteUserAccount: 'account.account.deleteUserAccount', + deleteUserAccounts: 'account.account.deleteUserAccounts', + getAccountByParam: 'account.account.getAccountByParam', + listByType: 'account.group.listByType', + }, + admin: { + account: { + list: 'account.admin.account.list', + }, + }, + group: { + create: 'account.group.create', + deleteList: 'account.group.deleteList', + getList: 'account.group.getList', + update: 'account.group.update', + }, + }, + plat: { + publish: { + create: 'plat.publish.create', + del: 'plat.publish.del', + }, + bilibili: { + getHeader: 'plat.bilibili.getHeader', + getAccountAuthInfo: 'plat.bilibili.getAccountAuthInfo', + authUrl: 'plat.bilibili.authUrl', + createAccountAndSetAccessToken: + 'plat.bilibili.createAccountAndSetAccessToken', + videoInit: 'plat.bilibili.videoInit', + uploadVideoPart: 'plat.bilibili.uploadVideoPart', + videoComplete: 'plat.bilibili.videoComplete', + coverUpload: 'plat.bilibili.coverUpload', + uploadLitVideo: 'plat.bilibili.uploadLitVideo', + archiveAddByUtoken: 'plat.bilibili.archiveAddByUtoken', + archiveTypeList: 'plat.bilibili.archiveTypeList', + getAuthInfo: 'plat.bilibili.getAuthInfo', + archiveList: 'plat.bilibili.archiveList', + userStat: 'plat.bilibili.userStat', + arcStat: 'plat.bilibili.arcStat', + arcIncStat: 'plat.bilibili.arcIncStat', + }, + kwai: { + authUrl: 'plat.kwai.authUrl', + addKwaiAccount: 'plat.kwai.addKwaiAccount', + }, + wxGzh: { + getAccountAuthInfo: 'plat.wxPlat.getAccountAuthInfo', + authUrl: 'plat.wxPlat.authUrl', + videoInit: 'plat.wxPlat.videoInit', + uploadVideoPart: 'plat.wxPlat.uploadVideoPart', + videoComplete: 'plat.wxPlat.videoComplete', + coverUpload: 'plat.wxPlat.coverUpload', + uploadLitVideo: 'plat.wxPlat.uploadLitVideo', + archiveAddByUtoken: 'plat.wxPlat.archiveAddByUtoken', + archiveTypeList: 'plat.wxPlat.archiveTypeList', + getAuthInfo: 'plat.wxPlat.getAuthInfo', + archiveList: 'plat.wxPlat.archiveList', + userStat: 'plat.wxPlat.userStat', + arcStat: 'plat.wxPlat.arcStat', + arcIncStat: 'plat.wxPlat.arcIncStat', + }, + youtube: { + getAccountAuthInfo: 'plat.youtube.getAccountAuthInfo', + authUrl: 'plat.youtube.authUrl', + setAccessToken: 'plat.youtube.setAccessToken', + getAuthInfo: 'plat.youtube.getAuthInfo', + isAuthorized: 'plat.youtube.isAuthorized', + getVideoCategories: 'plat.youtube.getVideoCategories', + getVideosList: 'plat.youtube.getVideosList', + uploadVideo: 'plat.youtube.uploadVideo', + initVideoUpload: 'plat.youtube.initVideoUpload', + uploadVideoPart: 'plat.youtube.uploadVideoPart', + videoComplete: 'plat.youtube.videoComplete', + }, + pinterest: { + getUserAccount: 'plat.pinterest.getUserAccount', + createAdAccount: 'plat.pinterest.createAdAccount', + getAdAccountById: 'plat.pinterest.getAdAccountById', + getAdAccountList: 'plat.pinterest.getAdAccountList', + createBoard: 'plat.pinterest.createBoard', + getBoardList: 'plat.pinterest.getBoardList', + getBoardById: 'plat.pinterest.getBoardById', + delBoardById: 'plat.pinterest.delBoardById', + createPin: 'plat.pinterest.createPin', + getPinById: 'plat.pinterest.getPinById', + getPinList: 'plat.pinterest.getPinList', + delPinById: 'plat.pinterest.delPinById', + }, + tiktok: { + authUrl: 'plat.tiktok.authUrl', + getAuthInfo: 'plat.tiktok.getAuthInfo', + createAccountAndSetAccessToken: + 'plat.tiktok.createAccountAndSetAccessToken', + refreshAccessToken: 'plat.tiktok.refreshAccessToken', + revokeAccessToken: 'plat.tiktok.revokeAccessToken', + getCreatorInfo: 'plat.tiktok.getCreatorInfo', + initVideoPublish: 'plat.tiktok.initVideoPublish', + initPhotoPublish: 'plat.tiktok.initPhotoPublish', + getPublishStatus: 'plat.tiktok.getPublishStatus', + uploadVideoFile: 'plat.tiktok.uploadVideoFile', + }, + twitter: { + authUrl: 'plat.twitter.authUrl', + getAuthInfo: 'plat.twitter.getAuthInfo', + createAccountAndSetAccessToken: + 'plat.twitter.createAccountAndSetAccessToken', + }, + }, + publish: { + pubRecord: { + create: 'publish.pubRecord.create', + del: 'publish.pubRecord.delete', + info: 'publish.pubRecord.info', + list: 'publish.pubRecord.list', + updateStatus: 'publish.pubRecord.updateStatus', + }, + }, + content: { + admin: { + material: { + listByIds: 'content.admin.material.listByIds', + }, + }, + material: { + create: 'content.material.create', + createTask: 'content.material.createTask', + preview: 'content.material.preview', + startTask: 'content.material.startTask', + del: 'content.material.delete', + delete: { + minUseCount: 'content.material.delete.minUseCount', + }, + updateInfo: 'content.material.updateInfo', + info: 'content.material.info', + list: 'content.material.list', + }, + materialGroup: { + create: 'content.materialGroup.create', + del: 'content.materialGroup.delete', + updateInfo: 'content.materialGroup.update', + info: 'content.materialGroup.info', + list: 'content.materialGroup.list', + }, + media: { + create: 'content.media.create', + del: 'content.media.delete', + info: 'content.media.info', + list: 'content.media.list', + addUseCount: 'content.media.addUseCount', + addUseCountOfList: 'content.media.addUseCountOfList', + }, + mediaGroup: { + create: 'content.mediaGroup.create', + del: 'content.mediaGroup.delete', + update: 'content.mediaGroup.update', + info: 'content.mediaGroup.info', + list: 'content.mediaGroup.list', + }, + }, + channel: { + publishRecord: { + userTask: 'channel.publishRecord.userTask', + }, + }, + other: { + feedback: { + create: 'other.feedback.create', + }, + appConfigs: { + getInfo: 'other.appConfigs.getInfo', + update: 'other.appConfigs.update', + batchUpdate: 'other.appConfigs.batchUpdate', + delete: 'other.appConfigs.delete', + getList: 'other.appConfigs.getList', + }, + }, + task: { + task: { + create: 'task.admin.task.create', + update: 'task.admin.task.update', + delete: 'task.admin.task.delete', + updateStatus: 'task.admin.task.updateStatus', + updateAutoDeleteMaterial: 'task.admin.task.updateAutoDeleteMaterial', + list: 'task.admin.task.list', + info: 'task.admin.task.info', + publish: { + accountList: 'task.admin.task.publish.accountList', + userList: 'task.admin.task.publish.userList', + }, + material: { + delete: 'task.admin.task.material.delete', + add: 'task.admin.task.material.add', + }, + }, + taskOpportunity: { + list: 'task.admin.taskOpportunity.list', + del: 'task.admin.taskOpportunity.del', + }, + userTask: { + list: 'task.admin.userTask.list', + info: 'task.admin.userTask.info', + verifyApproved: 'task.admin.userTask.verifyApproved', + verifyRejected: 'task.admin.userTask.verifyRejected', + rollbackApproved: 'task.admin.userTask.rollbackApproved', + }, + material: { + create: 'task.admin.material.create', + regenerate: 'task.admin.material.regenerate', + get: 'task.admin.material.get', + listByTaskId: 'task.admin.material.listByTaskId', + }, + notification: { + create: 'task.admin.notification.create', + list: 'task.admin.notification.list', + delete: 'task.admin.notification.delete', + }, + matcher: { + create: 'task.admin.matcher.create', + get: 'task.admin.matcher.get', + update: 'task.admin.matcher.update', + delete: 'task.admin.matcher.delete', + list: 'task.admin.matcher.list', + }, + rule: { + create: 'task.rule.create', + get: 'task.rule.get', + update: 'task.rule.update', + delete: 'task.rule.delete', + list: 'task.rule.list', + }, + accountPortrait: { + get: 'task.accountPortrait.get', + list: 'task.accountPortrait.list', + }, + userPortrait: { + get: 'task.userPortrait.get', + list: 'task.userPortrait.list', + }, + taskPunish: { + create: 'task.admin.taskPunish.create', + info: 'task.admin.taskPunish.info', + verifyApproved: 'task.admin.taskPunish.update', + delete: 'task.admin.taskPunish.delete', + list: 'task.admin.taskPunish.list', + }, + }, + payment: { + admin: { + checkout: { + list: 'payment.admin.checkout.list', + refund: 'admin.payment.refund', + subscription: 'admin.payment.subscription', + unsubscribe: 'admin.payment.subscription', + }, + withdraw: { + info: 'payment.admin.withdraw.info', + list: 'payment.admin.withdraw.list', + release: 'payment.admin.withdraw.release', + }, + }, + }, + ai: { + chat: 'ai.chat.generations', + chatModels: 'ai.chat.models', + imageGenerations: 'ai.image.generations', + imageEdits: 'ai.image.edits', + imageVariations: 'ai.image.variations', + videoGenerations: 'ai.video.generations', + videoTaskQuery: 'ai.video.task.query', + md2card: 'ai.md2card.generate', + fireflycard: 'ai.fireflycard.generate', + imageGenerationModels: 'ai.image.generation.models', + imageEditModels: 'ai.image.edit.models', + videoGenerationModels: 'ai.video.generation.models', + }, + statistics: { + account: { + getAccountDataLatest: 'statistics.account.getAccountDataLatest', + getAccountDataIncrease: 'statistics.account.getAccountDataIncrease', + getAccountDataByParams: 'statistics.account.getAccountDataByParams', + getAccountDataPeriod: 'statistics.account.getAccountDataPeriod', + getChannelDataLatestByUids: 'statistics.account.getChannelDataLatestByUids', + getChannelDataPeriodByUids: 'statistics.account.getChannelDataPeriodByUids', + }, + channelPosts: { + fetchChannelPosts: 'statistics.channel.posts', + }, + task: { + posts: { + dataCube: 'statistics.task.posts.dataCube', + periodDetail: 'statistics.task.posts.periodDetail', + }, + }, + post: { + detail: 'statistics.post.detail', + }, + }, +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.common.ts new file mode 100644 index 000000000..dec83d027 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.common.ts @@ -0,0 +1,49 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface BClient { + clientName: string + clientId: string + clientSecret: string + authBackUrl: string +} + +export enum NoReprint { + No = 1, + Yes = 0, +} + +export enum Copyright { + Original = 1, // 原创 + Reprint = 2, +} + +export interface BilibiliPublishOption { + tid: number // 分区ID,由获取分区信息接口得到 + no_reprint?: NoReprint // 是否允许转载 0-允许,1-不允许。默认0 + copyright: Copyright // 1-原创,2-转载(转载时source必填) + source?: string // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number // 参加的话题ID,默认情况下不填写,需要填写和运营联系 +} + +export type AddArchiveData = { + title: string // 标题 + cover?: string // 封面url + desc?: string // 描述 +} & BilibiliPublishOption + +export enum ArchiveStatus { + all = 'all', + is_pubing = 'is_pubing', + pubed = 'pubed', + not_pubed = 'not_pubed', +} + +export interface AccessToken { + access_token: string // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number // 1630220614; + refresh_token: string // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[] // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.natsApi.ts new file mode 100644 index 000000000..e11c29eb1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/bilibili.natsApi.ts @@ -0,0 +1,206 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { ChannelBaseApi } from '../../channelBase.api' +import { AccessToken, AddArchiveData, ArchiveStatus } from './bilibili.common' + +@Injectable() +export class PlatBilibiliNatsApi extends ChannelBaseApi { + /** + * 获取账号的授权信息 + * @param accountId + * @returns + */ + async getAccountAuthInfo(accountId: string) { + const res = await this.sendMessage( + `plat/bilibili/getAccountAuthInfo`, + { + accountId, + }, + ) + return res + } + + /** + * 获取授权页面URL + * @param userId + * @param type + * @returns + */ + async getAuth(userId: string, type: 'pc' | 'h5', spaceId: string) { + const res = await this.sendMessage<{ + url: string + taskId: string + }>( + `plat/bilibili/auth`, + { + userId, + type, + spaceId, + }, + ) + return res + } + + /** + * 创建账号 + * @param data + * @returns + */ + async createAccountAndSetAccessToken(data: { + taskId: string + code: string + state: string + }) { + const res = await this.sendMessage<{ + status: 0 | 1 + message?: string + accountId?: string + }>( + `plat/bilibili/createAccountAndSetAccessToken`, + data, + ) + return res + } + + /** + * 视频初始化 + * @param accountId + * @param name + * @param utype 上传类型:0,1。0-多分片,1-单个小文件(不超过100M)。默认值为0 + * @returns + */ + async videoInit(accountId: string, name: string, utype = 0) { + const res = await this.sendMessage( + `plat/bilibili/videoInit`, + { + accountId, + name, + utype, + }, + ) + return res + } + + /** + * 稿件发布 + * @param userId + * @param uploadToken + * @param data + * @returns + */ + async archiveAddByUtoken( + accountId: string, + uploadToken: string, + data: AddArchiveData, + ) { + const res = await this.sendMessage( + `plat/bilibili/archiveAddByUtoken`, + { + accountId, + uploadToken, + data, + }, + ) + return res + } + + /** + * 获取区域列表 + * @param accountId + * @returns + */ + async archiveTypeList(accountId: string) { + const res = await this.sendMessage( + `plat/bilibili/archiveTypeList`, + { + accountId, + }, + ) + return res + } + + /** + * 创建账号并设置授权Token + * @param taskId 任务ID + * @returns + */ + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/bilibili/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + /** + * 获取稿件列表 + * @param accountId + * @returns + */ + async getArchiveList( + accountId: string, + page: TableDto, + filter: { + status?: ArchiveStatus + }, + ) { + const res = await this.sendMessage( + `plat/bilibili/archiveList`, + { + accountId, + page, + filter, + }, + ) + return res + } + + /** + * 获取用户数据 + * @param accountId + * @returns + */ + async getUserStat(accountId: string) { + const res = await this.sendMessage( + `plat/bilibili/userStat`, + { + accountId, + }, + ) + return res + } + + /** + * 获取稿件数据 + * @param accountId + * @param resourceId + * @returns + */ + async getArcStat(accountId: string, resourceId: string) { + const res = await this.sendMessage( + `plat/bilibili/arcStat`, + { + accountId, + resourceId, + }, + ) + return res + } + + /** + * 获取稿件增量数据数据 + * @param accountId + * @returns + */ + async getArcIncStat(accountId: string) { + const res = await this.sendMessage( + `plat/bilibili/arcIncStat`, + { + accountId, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/common.ts new file mode 100644 index 000000000..87bf6384d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/common.ts @@ -0,0 +1,74 @@ +export enum FeedbackType { + errReport = 'errReport', // 错误反馈 + feedback = 'feedback', // 反馈 + msgReport = 'msgReport', // 消息举报 + msgFeedback = 'msgFeedback', // 消息反馈 +} + +export interface Feedback { + id: string + userId: string + userName: string + content: string + type: FeedbackType + tagList: string[] + fileUrlList: string[] + createAt: Date + updatedAt: Date +} + +export interface CreateFeedback { + userId: string + userName: string + content: string + type?: FeedbackType + tagList?: string[] + fileUrlList?: string[] +} + +export interface ChannelAccountDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number + // 稿件数量 + arcNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelAccountDataBulk extends ChannelAccountDataCube { + // 每天 + list: ChannelAccountDataCube[] +} + +export interface ChannelArcDataCube { + // 粉丝数 + fensNum?: number + // 播放量 + playNum?: number + // 评论数 + commentNum?: number + // 点赞数 + likeNum?: number + // 分享数 + shareNum?: number + // 收藏数 + collectNum?: number +} + +// 增量数据:分7天新增或30天新增 +export interface ChannelArcDataBulk extends ChannelAccountDataCube { + recordId: string + dataId: string + + // 每天 + list: ChannelArcDataCube[] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/engagement/engagement.api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/engagement/engagement.api.ts new file mode 100644 index 000000000..c9c79ba12 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/engagement/engagement.api.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common' +import { AIGenCommentDto, AIGenCommentResponseVo, FetchAllPostsRequestDto, FetchCommentRepliesDto, FetchPostCommentsRequestDto, FetchPostCommentsResponseDto, FetchPostsRequestDto, FetchPostsResponseVo, PublishCommentReplyRequestDto, PublishCommentRequestDto, PublishCommentResponseDto, ReplyToCommentsDto } from '../../../../channel/engagement/dto/engagement.dto' +import { ChannelBaseApi } from '../../../channelBase.api' + +@Injectable() +export class EngagementNatsApi extends ChannelBaseApi { + async fetchPostComments(payload: FetchPostCommentsRequestDto) { + const res = await this.sendMessage( + `channel/engagement/list/post/comments`, + payload, + ) + return res + } + + async fetchCommentReplies(payload: FetchCommentRepliesDto) { + const res = await this.sendMessage( + `channel/engagement/list/comment/replies`, + payload, + ) + return res + } + + async commentOnPost(payload: PublishCommentRequestDto) { + const res = await this.sendMessage( + `channel/engagement/publish/post/comment`, + payload, + ) + return res + } + + async replyToComment(payload: PublishCommentReplyRequestDto) { + const res = await this.sendMessage( + `channel/engagement/publish/comment/reply`, + payload, + ) + return res + } + + async fetchChannelPosts(payload: FetchPostsRequestDto) { + const res = await this.sendMessage( + `statistics/channelPosts/fetchChannelPosts`, + payload, + ) + return res + } + + async fetchChannelAllPosts(payload: FetchAllPostsRequestDto) { + const res = await this.sendMessage( + `statistics/channelPosts/fetchChannelAllPosts`, + payload, + ) + return res + } + + async generateRepliesByAI(userId: string, payload: AIGenCommentDto) { + const res = await this.sendMessage( + `channel/engagement/ai/generate/replies`, + { + userId, + ...payload, + }, + ) + return res + } + + async replyToCommentsByAI(userId: string, payload: ReplyToCommentsDto) { + const res = await this.sendMessage( + `channel/engagement/ai/reply/to/comments`, + { + userId, + ...payload, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/common.ts new file mode 100644 index 000000000..19e63ab9f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/common.ts @@ -0,0 +1,29 @@ +import { AccountType } from '@yikart/common' + +export interface InteractionRecord { + id: string + userId: string + accountId: string + type: AccountType + worksId: string + worksTitle?: string + commentRemark?: string + worksCover?: string + commentContent: string + isLike: 0 | 1 + isCollect: 0 | 1 + createAt: Date + updatedAt: Date +} + +export interface ReplyCommentRecord { + id: string + userId: string + accountId: string + type: AccountType + commentId: string + commentContent: string + replyContent: string + createAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interact.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interact.natsApi.ts new file mode 100644 index 000000000..40395dec6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interact.natsApi.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { ChannelBaseApi } from '../../../channelBase.api' + +@Injectable() +export class InteractNatsApi extends ChannelBaseApi { + /** + * 添加作品评论 + * @returns + */ + async addArcComment(accountId: string, dataId: string, content: string) { + const res = await this.sendMessage( + `channel/interact/addArcComment`, + { + accountId, + dataId, + content, + }, + ) + return res + } + + /** + * 获取作品评论列表 + * @returns + */ + async getArcCommentList(recordId: string, query: TableDto) { + const res = await this.sendMessage<{ + list: any[] + total: number + }>( + `channel/interact/getArcCommentList`, + { + recordId, + pageNo: query.pageNo, + pageSize: query.pageSize, + }, + ) + return res + } + + /** + * 回复评论 + * @param accountId + * @param commentId + * @returns + */ + async replyComment(accountId: string, commentId: string, content: string) { + const res = await this.sendMessage( + `channel/interact/replyComment`, + { + accountId, + commentId, + content, + }, + ) + return res + } + + /** + * 删除评论 + * @param accountId + * @param commentId + * @returns + */ + async delComment(accountId: string, commentId: string) { + const res = await this.sendMessage( + `channel/interact/delComment`, + { + accountId, + commentId, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interactionRecord.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interactionRecord.natsApi.ts new file mode 100644 index 000000000..8f4590a1e --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/interactionRecord.natsApi.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common' +import { AccountType, TableDto } from '@yikart/common' +import { ChannelBaseApi } from '../../../channelBase.api' +import { InteractionRecord } from './common' + +@Injectable() +export class InteractionRecordNatsApi extends ChannelBaseApi { + async add(data: { + userId: string + accountId: string + type: AccountType + worksId: string + worksTitle?: string + worksCover?: string + worksContent?: string + commentContent?: string + commentRemark?: string + commentTime?: string + likeTime?: string + collectTime?: string + }) { + const res = await this.sendMessage( + `channel/interactionRecord/add`, + data, + ) + return res + } + + /** + * 获取作品评论列表 + * @returns + */ + async list(userId: string, filters: { + accountId?: string + type?: AccountType + worksId?: string + time?: [Date, Date] + }, page: TableDto) { + const res = await this.sendMessage<{ + list: InteractionRecord[] + total: number + }>( + `channel/interactionRecord/list`, + { + filters: { + userId, + ...filters, + }, + page, + }, + ) + return res + } + + /** + * 删除 + * @param id + * @returns + */ + async del(id: string) { + const res = await this.sendMessage( + `channel/interactionRecord/del`, + { + id, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/replyCommentRecord.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/replyCommentRecord.natsApi.ts new file mode 100644 index 000000000..440f2300c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/interact/replyCommentRecord.natsApi.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common' +import { AccountType, TableDto } from '@yikart/common' +import { ChannelBaseApi } from '../../../channelBase.api' +import { ReplyCommentRecord } from './common' + +@Injectable() +export class ReplyCommentRecordNatsApi extends ChannelBaseApi { + async add(data: { + userId: string + accountId: string + type: AccountType + worksId?: string + commentId: string + commentContent: string + replyContent: string + }) { + const res = await this.sendMessage( + `channel/replyCommentRecord/add`, + data, + ) + return res + } + + async list(userId: string, filters: { + accountId?: string + type?: AccountType + commentId?: string + time?: [Date, Date] + }, page: TableDto) { + const res = await this.sendMessage<{ + list: ReplyCommentRecord[] + total: number + }>( + `channel/replyCommentRecord/list`, + { + filters: { + userId, + ...filters, + }, + page, + }, + ) + return res + } + + /** + * 删除 + * @param id + * @returns + */ + async del(id: string) { + const res = await this.sendMessage( + `channel/replyCommentRecord/del`, + { + id, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/kwai.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/kwai.natsApi.ts new file mode 100644 index 000000000..b29ee44b0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/kwai.natsApi.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatKwaiNatsApi extends ChannelBaseApi { + // 获取页面的认证URL + async getAuth(data: { type: 'h5' | 'pc', userId: string, spaceId: string }) { + const res = await this.sendMessage<{ + url: string + taskId: string + }>( + `plat/kwai/auth`, + data, + ) + return res + } + + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/kwai/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + async createAccountAndSetAccessToken(data: { + taskId: string + code: string + state: string + }) { + const res = await this.sendMessage<{ + status: 0 | 1 + message?: string + accountId?: string + }>( + `plat/kwai/createAccountAndSetAccessToken`, + data, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.common.ts new file mode 100644 index 000000000..e62ffe5a6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.common.ts @@ -0,0 +1,49 @@ +export interface FacebookPostOptions { + content_category: string + content_tags?: string[] + custom_labels?: string[] + direct_share_status?: number + embeddable?: boolean +} + +export interface ProductTag { + product_id: string + x: number + y: number +} + +export interface UserTag { + username: string + x: number + y: number +} + +export interface InstagramPostOptions { + content_category: string + alt_text?: string + caption?: string + collaborators?: string[] + cover_url?: string + image_url?: string + location_id?: string + product_tags?: ProductTag[] + user_tags?: UserTag[] +} + +export interface ThreadsPostOptions { + reply_control?: string + location_id?: string + allowlisted_country_codes?: string[] + alt_text?: string + auto_publish_text?: boolean + topic_tags?: string +} + +export interface TiktokPostOptions { + privacy_level: 'PUBLIC_TO_EVERYONE' | 'MUTUAL_FOLLOW_FRIENDS' | 'SELF_ONLY' + disable_duet?: boolean + disable_stitch?: boolean + disable_comment?: boolean + brand_organic_toggle?: boolean + brand_content_toggle?: boolean +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.natsApi.ts new file mode 100644 index 000000000..bac9e208d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/meta.natsApi.ts @@ -0,0 +1,211 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatMetaNatsApi extends ChannelBaseApi { + async getAuthUrl(userId: string, platform: string, spaceId: string) { + const res = await this.sendMessage( + `plat/meta/authUrl`, + { + userId, + platform, + spaceId, + }, + ) + return res + } + + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/meta/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + async getFacebookPages(userId: string) { + const res = await this.sendMessage( + `plat/meta/facebook/pages`, + { + userId, + }, + ) + return res + } + + async selectFacebookPages( + userId: string, + pageIds: string[], + ) { + const res = await this.sendMessage( + `plat/meta/facebook/pages/selection`, + { + userId, + pageIds, + }, + ) + return res + } + + async createAccountAndSetAccessToken( + code: string, + state: string, + ) { + const res = await this.sendMessage( + `plat/meta/createAccountAndSetAccessToken`, + { + code, + state, + }, + ) + return res + } + + async getFacebookPagePublishedPosts( + userId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getFacebookPagePublishedPosts`, + { + userId, + query, + }, + ) + return res + } + + async getFacebookPageInsights( + userId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getFacebookPageInsights`, + { + userId, + query, + }, + ) + return res + } + + async getFacebookPostInsights( + userId: string, + postId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getFacebookPostInsights`, + { + userId, + postId, + query, + }, + ) + return res + } + + async getInstagramAccountInfo( + userId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getInstagramAccountInfo`, + { + userId, + query, + }, + ) + return res + } + + async getInstagramAccountInsights( + userId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getInstagramAccountInsights`, + { + userId, + query, + }, + ) + return res + } + + async getInstagramPostInsights( + userId: string, + postId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getInstagramPostInsights`, + { + userId, + postId, + query, + }, + ) + return res + } + + async getThreadsAccountInsights( + userId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getThreadsAccountInsights`, + { + userId, + query, + }, + ) + return res + } + + async getThreadsPostInsights( + userId: string, + postId: string, + query: any, + ) { + const res = await this.sendMessage( + `plat/meta/getThreadsPostInsights`, + { + userId, + postId, + query, + }, + ) + return res + } + + async searchFacebookLocations( + userId: string, + keyword: string, + ) { + const res = await this.sendMessage( + `plat/meta/facebool/search/locations`, + { + userId, + keyword, + }, + ) + return res + } + + async searchThreadsLocations( + accountId: string, + keyword: string, + ) { + const res = await this.sendMessage<{ id: string, label: string }[]>( + `plat/meta/threads/search/locations`, + { + accountId, + keyword, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/pinterest.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/pinterest.natsApi.ts new file mode 100644 index 000000000..5506a6df0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/pinterest.natsApi.ts @@ -0,0 +1,130 @@ +import { Injectable } from '@nestjs/common' +import { CreateBoardBodyDto, CreatePinBodyDto, ListBodyDto, WebhookDto } from '../../../channel/pinterest/dto/pinterest.dto' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatPinterestNatsApi extends ChannelBaseApi { + /** + * 创建board + * @param data + */ + async createBoard(data: CreateBoardBodyDto) { + const res = await this.sendMessage( + `plat/pinterest/createBoard`, + data, + ) + return res + } + + /** + * 获取boardList + */ + async getBoardList(body: ListBodyDto) { + const res = await this.sendMessage( + `plat/pinterest/getBoardList`, + body, + ) + return res + } + + /** + * 获取单个board + */ + async getBoardById(id: string, accountId: string) { + const res = await this.sendMessage( + `plat/pinterest/getBoardById`, + { id, accountId }, + ) + return res + } + + /** + * 删除单个board + */ + async delBoardById(id: string, accountId: string) { + const res = await this.sendMessage( + `plat/pinterest/delBoardById`, + { id, accountId }, + ) + return res + } + + /** + * 创建pin + * + */ + async createPin(data: CreatePinBodyDto) { + const res = await this.sendMessage( + `plat/pinterest/createPin`, + data, + ) + return res + } + + /** + * 创建pin + * + */ + async getPinById(id: string, accountId: string) { + const res = await this.sendMessage( + `plat/pinterest/getPinById`, + { id, accountId }, + ) + return res + } + + /** + * 获取pin List + */ + async getPinList(body: ListBodyDto) { + const res = await this.sendMessage( + `plat/pinterest/getPinList`, + body, + ) + return res + } + + /** + * 删除pin + */ + async delPinById(id: string, accountId: string) { + const res = await this.sendMessage( + `plat/pinterest/delPinById`, + { id, accountId }, + ) + return res + } + + /** + * 获取用户授权地址 + */ + async getAuth(userId: string, spaceId: string) { + const res = await this.sendMessage( + `plat/pinterest/getAuth`, + { userId, spaceId }, + ) + return res + } + + /** + * 查询授权结果 + */ + async checkAuth(taskId: string) { + const res = await this.sendMessage( + `plat/pinterest/checkAuth`, + { taskId }, + ) + return res + } + + /** + * 回调地址 + */ + async authWebhook(data: WebhookDto) { + const res = await this.sendMessage( + `plat/pinterest/authWebhook`, + data, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publish.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publish.natsApi.ts new file mode 100644 index 000000000..cc2dc6469 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publish.natsApi.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common' +import { AccountType, TableDto } from '@yikart/common' +import { PublishStatus, PublishType } from '@yikart/mongodb' +import { NewPublishData, NewPublishRecordData, PlatOptions } from '../../../channel/common' +import { PublishDayInfoListFiltersDto } from '../../../channel/dto/publish.dto' +import { ChannelBaseApi } from '../../channelBase.api' +import { PublishRecordItem } from './types/publish.interfaces' + +@Injectable() +export class PlatPublishNatsApi extends ChannelBaseApi { + /** + * 创建发布 + * @returns + * @param newData + */ + async create(newData: NewPublishData) { + const res = await this.sendMessage( + `plat/publish/create`, + newData, + ) + return res + } + + /** + * 执行发布任务 + * @returns + * @param id + */ + async run(id: string) { + const res = await this.sendMessage( + `plat/publish/run`, + { id }, + ) + return res + } + + /** + * 创建发布记录 + * @returns + * @param newData + */ + async createRecord(newData: NewPublishRecordData) { + const res = await this.sendMessage( + `plat/publish/createRecord`, + newData, + ) + return res + } + + // 获取发布记录 + async getPublishRecordList(filter: { + userId: string + accountId?: string + accountType?: AccountType + type?: PublishType + status?: PublishStatus + time?: [Date, Date] + }) { + const res = await this.sendMessage( + `plat/publish/getList`, + { ...filter }, + ) + return res + } + + // 修改发布任务时间 + async updatePublishRecordTime(data: { + publishTime: Date + userId: string + id: string + }) { + const res = await this.sendMessage( + `plat/publish/changeTime`, + data, + ) + return res + } + + // 删除发布任务 + async deletePublishRecord(data: { userId: string, id: string }) { + const res = await this.sendMessage( + `plat/publish/delete`, + data, + ) + return res + } + + // 立即发布任务 + async nowPubTask(id: string) { + const res = await this.sendMessage( + `publish/task/run`, + { + id, + }, + ) + return res + } + + // 获取发布数据信息 + async getPublishInfoData(userId: string) { + const res = await this.sendMessage<{ + totalCount: number + list: PublishRecordItem[] + }>( + `plat/publish/publishInfo`, + { + userId, + }, + ) + return res + } + + async publishDataInfoList(userId: string, data: PublishDayInfoListFiltersDto, page: TableDto) { + const res = await this.sendMessage( + `plat/publish/PublishDayInfo/list`, + { + filters: { + userId, + ...data, + }, + page, + }, + ) + return res + } + + async getPublishRecordDetail(flowId: string, userId: string) { + const res = await this.sendMessage( + `plat/publish/recordDetail`, + { + flowId, + userId, + }, + ) + return res + } + + async getPublishTaskDetail(flowId: string, userId: string) { + const res = await this.sendMessage( + `channel/publishing/task/detail`, + { + flowId, + userId, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publishTask.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publishTask.natsApi.ts new file mode 100644 index 000000000..f09753e66 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/publishTask.natsApi.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common' +import { PubRecordListFilterDto } from '../../../channel/dto/publish.dto' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PublishTaskNatsApi extends ChannelBaseApi { + async getPublishTaskList(userId: string, query: PubRecordListFilterDto) { + const res = await this.sendMessage( + `channel/publishTask/list`, + { userId, ...query }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/skKeyNatsApi.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/skKeyNatsApi.natsApi.ts new file mode 100644 index 000000000..f4ce9d7a6 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/skKeyNatsApi.natsApi.ts @@ -0,0 +1,109 @@ +import { Injectable } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { SkKey } from '../../../channel/skKey/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class ChannelSkKeyNatsApi extends ChannelBaseApi { + async create(userId: string, desc?: string) { + const res = await this.sendMessage<{ + key: string + }>( + `channel/skKey/create`, + { + userId, + desc, + }, + ) + return res + } + + async del(key: string) { + const res = await this.sendMessage( + `channel/skKey/del`, + { + key, + }, + ) + return res + } + + async upInfo(key: string, desc: string) { + const res = await this.sendMessage( + `channel/skKey/upInfo`, + { + key, + desc, + }, + ) + return res + } + + async getInfo(key: string) { + const res = await this.sendMessage<{ + key: string + desc: string + }>( + `channel/skKey/getInfo`, + { + key, + }, + ) + return res + } + + async list( + page: TableDto, + query: { + userId: string + }, + ) { + const res = await this.sendMessage<{ + list: SkKey + total: number + }>( + `channel/skKey/list`, + { + ...page, + ...query, + }, + ) + return res + } + + async addRefAccount(key: string, accountId: string) { + const res = await this.sendMessage<{ + key: string + accountId: string + }>( + `channel/skKey/addRefAccount`, + { + key, + accountId, + }, + ) + return res + } + + async delRefAccount(key: string, accountId: string) { + const res = await this.sendMessage( + `channel/skKey/delRefAccount`, + { + key, + accountId, + }, + ) + return res + } + + async getRefAccountList(key: string, page: TableDto) { + const res = await this.sendMessage( + `channel/skKey/getRefAccountList`, + { + key, + ...page, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/tiktok.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/tiktok.natsApi.ts new file mode 100644 index 000000000..0589c9cd1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/tiktok.natsApi.ts @@ -0,0 +1,209 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatTiktokNatsApi extends ChannelBaseApi { + /** + * 获取授权页面URL + * @param userId 用户ID + * @param scopes 权限范围 + * @param spaceId + * @returns + */ + async getAuthUrl(userId: string, scopes?: string[], spaceId?: string) { + const res = await this.sendMessage( + `plat/tiktok/authUrl`, + { + userId, + scopes, + spaceId: spaceId || '', + }, + ) + return res + } + + /** + * 查询认证信息 + * @param taskId 任务ID + * @returns + */ + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/tiktok/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + /** + * 创建账号并设置授权Token + * @param taskId 任务ID + * @param code 授权码 + * @param state 状态码 + * @returns + */ + async createAccountAndSetAccessToken( + code: string, + state: string, + ) { + const res = await this.sendMessage<{ + status: 0 | 1 + message?: string + accountId?: string + }>( + `plat/tiktok/createAccountAndSetAccessToken`, + { + code, + state, + }, + ) + return res + } + + /** + * 刷新访问令牌 + * @param accountId 账号ID + * @param refreshToken 刷新令牌 + * @returns + */ + async refreshAccessToken(accountId: string, refreshToken: string) { + const res = await this.sendMessage( + `plat/tiktok/refreshAccessToken`, + { + accountId, + refreshToken, + }, + ) + return res + } + + /** + * 撤销访问令牌 + * @param accountId 账号ID + * @returns + */ + async revokeAccessToken(accountId: string) { + const res = await this.sendMessage( + `plat/tiktok/revokeAccessToken`, + { + accountId, + }, + ) + return res + } + + /** + * 获取创作者信息 + * @param accountId 账号ID + * @returns + */ + async getCreatorInfo(accountId: string) { + const res = await this.sendMessage( + `plat/tiktok/getCreatorInfo`, + { + accountId, + }, + ) + return res + } + + /** + * 初始化视频发布 + * @param accountId 账号ID + * @param postInfo 发布信息 + * @param sourceInfo 源信息 + * @returns + */ + async initVideoPublish(accountId: string, postInfo: any, sourceInfo: any) { + const res = await this.sendMessage( + `plat/tiktok/initVideoPublish`, + { + accountId, + postInfo, + sourceInfo, + }, + ) + return res + } + + /** + * 初始化照片发布 + * @param accountId 账号ID + * @param postMode 发布模式 + * @param postInfo 发布信息 + * @param sourceInfo 源信息 + * @returns + */ + async initPhotoPublish( + accountId: string, + postMode: string, + postInfo: any, + sourceInfo: any, + ) { + const res = await this.sendMessage( + `plat/tiktok/initPhotoPublish`, + { + accountId, + postMode, + postInfo, + sourceInfo, + }, + ) + return res + } + + /** + * 查询发布状态 + * @param accountId 账号ID + * @param publishId 发布ID + * @returns + */ + async getPublishStatus(accountId: string, publishId: string) { + const res = await this.sendMessage( + `plat/tiktok/getPublishStatus`, + { + accountId, + publishId, + }, + ) + return res + } + + /** + * 上传视频文件 + * @param uploadUrl 上传URL + * @param videoBase64 视频Base64 + * @param contentType 内容类型 + * @returns + */ + async uploadVideoFile( + uploadUrl: string, + videoBase64: string, + contentType: string, + ) { + const res = await this.sendMessage( + `plat/tiktok/uploadVideoFile`, + { + uploadUrl, + videoBase64, + contentType, + }, + ) + return res + } + + /** + * 处理TikTok Webhook事件 + * @param event Webhook事件数据 + * @returns + */ + async handleWebhookEvent(event: any) { + const res = await this.sendMessage( + `publish/tiktok/post/webhook`, + event, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/twitter.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/twitter.natsApi.ts new file mode 100644 index 000000000..b6634dd3c --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/twitter.natsApi.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatTwitterNatsApi extends ChannelBaseApi { + async getAuthUrl(userId: string, scopes?: string[], spaceId?: string) { + const res = await this.sendMessage( + `plat/twitter/authUrl`, + { + userId, + scopes, + spaceId: spaceId || '', + }, + ) + return res + } + + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/twitter/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + async createAccountAndSetAccessToken( + code: string, + state: string, + ) { + const res = await this.sendMessage<{ + status: 0 | 1 + message?: string + accountId?: string + }>( + `plat/twitter/createAccountAndSetAccessToken`, + { + code, + state, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/types/publish.interfaces.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/types/publish.interfaces.ts new file mode 100644 index 000000000..723d8d94a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/types/publish.interfaces.ts @@ -0,0 +1,28 @@ +import { AccountType } from '@yikart/common' +import { PublishStatus } from '@yikart/mongodb' + +export interface PublishRecordItem { + dataId: string + id: string + flowId: string + type: string + title: string + desc: string + accountId: string + accountType: AccountType + uid: string + videoUrl?: string + coverUrl?: string + imgUrlList: string[] + publishTime: Date + status: PublishStatus + errorMsg: string + option: any +} + +export interface PublishDayInfo { + userId: string + publishTotal: number + createAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.common.ts new file mode 100644 index 000000000..74f0664a2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.common.ts @@ -0,0 +1,3 @@ +export interface WxGzhPublishOption { + tid: number // 分区ID,由获取分区信息接口得到 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.natsApi.ts new file mode 100644 index 000000000..d956352de --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/wxGzh.natsApi.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' + +@Injectable() +export class PlatWxGzhNatsApi extends ChannelBaseApi { + /** + * 创建授权任务 + * @param userId + * @param type + * @param prefix + * @returns + */ + async createAuthTask(userId: string, type: 'pc' | 'h5', prefix?: string, spaceId?: string) { + const res = await this.sendMessage<{ + url: string + taskId: string + }>( + `plat/wxPlat/auth`, + { + userId, + type, + prefix, + spaceId, + }, + ) + return res + } + + /** + * 获取授权任务信息 + * @param taskId + * @returns + */ + async getAuthTaskInfo(taskId: string) { + const res = await this.sendMessage( + `plat/wxPlat/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + async createAccountAndSetAccessToken(query: { + taskId: string + auth_code: string + expires_in: number + }) { + const res = await this.sendMessage( + `channel/wxPlat/createAccountAndSetAccessToken`, + query, + ) + return res + } + + async updatePublishRecord(data: { + publish_id: string + appId: string + article_url?: string + article_id: string + }) { + const res = await this.sendMessage( + `channel/wxPlat/updatePublishRecord`, + data, + ) + return res + } + + /** + * 获取账号的授权信息 + * @param accountId + * @returns + */ + async getAccountAuthInfo(accountId: string) { + const res = await this.sendMessage( + `plat/wxPlat/getAccountAuthInfo`, + { + accountId, + }, + ) + return res + } + + /** + * 获取累计用户数据 + * @param accountId + * @param beginDate 开始日期 + * @param endDate 结束日期(最大跨度7天) + * @returns + */ + async getUserCumulate(accountId: string, beginDate: string, endDate: string) { + const res = await this.sendMessage( + `plat/wxGzh/getUserCumulate`, + { + accountId, + beginDate, + endDate, + }, + ) + return res + } + + /** + * 获取图文阅读概况数据 + * @param accountId 开始日期 + * @param beginDate 开始日期 + * @param endDate 结束日期(最大值为昨日) + * @returns + */ + async getUserRead(accountId: string, beginDate: string, endDate: string) { + const res = await this.sendMessage( + `plat/wxGzh/getUserRead`, + { + accountId, + beginDate, + endDate, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.common.ts new file mode 100644 index 000000000..a7d240c01 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.common.ts @@ -0,0 +1,6 @@ +export interface YoutubePublishOption { + privacyStatus?: string // 隐私状态 + tag?: string // 标签, 多个标签用英文逗号分隔,总长度小于200 + categoryId?: string // 分类id + publishAt?: string // 定时发布 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.natsApi.ts new file mode 100644 index 000000000..9758807fc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/api/youtube.natsApi.ts @@ -0,0 +1,769 @@ +import { Injectable } from '@nestjs/common' +import { ChannelBaseApi } from '../../channelBase.api' +import { AccessToken } from './bilibili.common' + +@Injectable() +export class PlatYoutubeNatsApi extends ChannelBaseApi { + /** + * 获取授权页面URL + * @param userId + * @param mail + * @param type + * @param prefix + * @returns + */ + async getAuthUrl( + userId: string, + mail: string, + prefix?: string, + spaceId?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/authUrl`, + { + userId, + mail, + prefix, + spaceId, + }, + ) + return res + } + + /** + * 创建账号 + * @param data + * @param prefix + * @returns + */ + async setAccessToken( + data: { + taskId: string + code: string + state: string + }, + ) { + const res = await this.sendMessage<{ + status: number + message: string + accountId: any + }>( + `plat/youtube/setAccessToken`, + data, + ) + return res + } + + /** + * 获取账号的授权信息 + * @param accountId + * @returns + */ + async getAccountAuthInfo(accountId: string) { + const res = await this.sendMessage( + `plat/youtube/getAccountAuthInfo`, + { + accountId, + }, + ) + return res + } + + /** + * 创建账号并设置授权Token + * @param taskId 任务ID + * @returns + */ + async getAuthInfo(taskId: string) { + const res = await this.sendMessage( + `plat/youtube/getAuthInfo`, + { + taskId, + }, + ) + return res + } + + /** + * 查询账号是否授权 + * @param accountId + * @returns + */ + async isAuthorized(accountId: string) { + const res = await this.sendMessage( + `plat/youtube/isAuthorized`, + { + accountId, + }, + ) + return res + } + + /** + * 刷新令牌token + * @param accountId + * @returns + */ + async refreshToken(accountId: string) { + const res = await this.sendMessage( + `plat/youtube/refreshToken`, + { + accountId, + }, + ) + return res + } + + /** + * 获取视频类别列表 + * @param accountId 账户ID + * @param id 类别ID(可选) + * @param regionCode 地区代码(可选) + * @returns 视频类别列表 + */ + async getVideoCategories( + accountId: string, + id?: string, + regionCode?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getVideoCategories`, + { + accountId, + id, + regionCode, + }, + ) + return res + } + + /** + * 获取视频列表 + * @param accountId 账户ID + * @param id 视频ID(可选) + * @param myRating 是否获取我评分的视频(可选) + * @param maxResults 最大返回结果数(可选) + * @param pageToken 分页令牌(可选) + * @returns 视频列表 + */ + async getVideosList( + accountId: string, + chart?: string, + id?: string, + myRating?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getVideosList`, + { + accountId, + chart, + id, + myRating, + maxResults, + pageToken, + }, + ) + return res + } + + /** + * 上传视频到YouTube + * @param accountId 账户ID + * @param fileBuffer 视频文件Buffer + * @param fileName 文件名 + * @param title 视频标题 + * @param description 视频描述 + * @param privacyStatus 隐私状态(public, private, unlisted) + * @param keywords 关键词(可选) + * @param categoryId 视频类别ID(可选) + * @param publishAt 发布时间(可选) + * @returns 上传结果 + */ + async uploadVideo( + accountId: string, + fileBuffer: Buffer, + fileName: string, + title: string, + description: string, + privacyStatus: string, + keywords?: string, + categoryId?: string, + publishAt?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/uploadVideo`, + { + accountId, + fileBuffer, + fileName, + title, + description, + privacyStatus, + keywords, + categoryId, + publishAt, + }, + ) + return res + } + + /** + * 初始化视频分片上传 + * @param accountId 账户ID + * @param title 视频标题 + * @param description 视频描述 + * @param keywords 关键词(可选) + * @param categoryId 视频类别ID(可选) + * @param privacyStatus 隐私状态(public, private, unlisted)(可选) + * @param publishAt 发布时间(可选) + * @param contentLength 视频文件大小(字节) + * @returns 上传会话信息 + */ + async initVideoUpload( + accountId: string, + title: string, + description: string, + keywords?: string, + categoryId?: string, + privacyStatus?: string, + publishAt?: string, + contentLength?: number, + ) { + // 确保类型转换正确 + const payload = { + accountId: String(accountId), + title, + description, + keywords, + categoryId, + privacyStatus, + publishAt, + // 确保contentLength是数字类型 + contentLength: contentLength ? Number(contentLength) : undefined, + } + + const res = await this.sendMessage( + `plat/youtube/initVideoUpload`, + payload, + ) + return res + } + + /** + * 上传视频分片 + * @param accountId 账户ID + * @param fileBase64 分片数据的Base64编码字符串 + * @param uploadToken 上传令牌 + * @param partNumber 分片序号 + * @returns 上传结果 + */ + async uploadVideoPart( + accountId: string, + fileBase64: string, + uploadToken: string, + partNumber: number, + ) { + const res = await this.sendMessage( + `plat/youtube/uploadVideoPart`, + { + accountId, + fileBase64, + uploadToken, + partNumber, + }, + ) + return res + } + + /** + * 完成视频上传 + * @param accountId 账户ID + * @param uploadToken 上传令牌 + * @param totalSize 视频文件的总大小(字节) + * @returns 完成结果 + */ + async videoComplete( + accountId: string, + uploadToken: string, + totalSize: number, + ) { + // 确保参数为基本类型,避免序列化问题 + const payload = { + accountId: String(accountId), + uploadToken: String(uploadToken), + totalSize: Number(totalSize), // 确保totalSize是数字类型 + } + + const res = await this.sendMessage( + `plat/youtube/videoComplete`, + payload, + ) + return res + } + + /** + * 获取子评论列表 + * @param accountId 账户ID + * @param id 评论ID + * @param parentId 父评论ID + * @param maxResults 最大返回结果数 + * @param pageToken 分页令牌 + * @returns 评论列表 + */ + async getCommentsList( + accountId: string, + id?: string, + parentId?: string, + maxResults?: number, + pageToken?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getCommentsList`, + { + accountId, + id, + parentId, + maxResults, + pageToken, + }, + ) + return res + } + + // 创建顶级评论(评论会话) + async insertCommentThreads( + accountId: string, + channelId: string, + videoId: string, + textOriginal: string, + ) { + const res = await this.sendMessage( + `plat/youtube/insertCommentThreads`, + { + accountId, + channelId, + videoId, + textOriginal, + }, + ) + return res + } + + // 获取评论会话列表 + async getCommentThreadsList( + accountId: string, + allThreadsRelatedToChannelId?: string, + id?: string, + videoId?: string, + maxResults?: number, + pageToken?: string, + order?: string, + searchTerms?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getCommentThreadsList`, + { + accountId, + allThreadsRelatedToChannelId, + id, + videoId, + maxResults, + pageToken, + order, + searchTerms, + }, + ) + return res + } + + // 创建二级评论 + async insertComment( + accountId: string, + parentId?: string, + textOriginal?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/insertComment`, + { + accountId, + parentId, + textOriginal, + }, + ) + return res + } + + // 更新评论 + async updateComment( + accountId: string, + parentId: string, + textOriginal: string, + ) { + const res = await this.sendMessage( + `plat/youtube/updateComment`, + { + accountId, + parentId, + textOriginal, + }, + ) + return res + } + + // 删除评论 + async deleteComment(accountId: string, id: string) { + const res = await this.sendMessage( + `plat/youtube/deleteComment`, + { + accountId, + id, + }, + ) + return res + } + + // 设置评论状态 + async setModerationStatusComments( + accountId: string, + id: string, + moderationStatus?: string, + banAuthor?: boolean, + ) { + const res = await this.sendMessage( + `plat/youtube/setModerationStatusComments`, + { + accountId, + id, + moderationStatus, + banAuthor, + }, + ) + return res + } + + // 对视频点赞、踩 + async setVideoRate(accountId: string, id: string, rating: string) { + const res = await this.sendMessage( + `plat/youtube/setVideoRate`, + { + accountId, + id, + rating, + }, + ) + return res + } + + // 获取视频的点赞、踩数 + async getVideoRate(accountId: string, id: string) { + const res = await this.sendMessage( + `plat/youtube/getVideoRate`, + { + accountId, + id, + }, + ) + return res + } + + // 删除视频 + async deleteVideo(accountId: string, id: string) { + const res = await this.sendMessage( + `plat/youtube/deleteVideo`, + { + accountId, + id, + }, + ) + return res + } + + // 更新视频 + async updateVideo( + accountId: string, + id: string, + title: string, + categoryId: string, + defaultLanguage?: string, + description?: string, + privacyStatus?: string, + tags?: string, + publishAt?: string, + recordingDate?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/updateVideo`, + { + accountId, + id, + title, + categoryId, + defaultLanguage, + description, + privacyStatus, + tags, + publishAt, + recordingDate, + }, + ) + return res + } + + // 创建播放列表 + async createPlaylist( + accountId: string, + title: string, + description?: string, + privacyStatus?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/createPlaylist`, + { + accountId, + title, + description, + privacyStatus, + }, + ) + return res + } + + // 获取播放列表 + async getPlayList( + accountId: string, + channelId?: string, + id?: string, + mine?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getPlayList`, + { + accountId, + channelId, + id, + mine, + maxResults, + pageToken, + }, + ) + return res + } + + // 更新播放列表 + async updatePlaylist( + accountId: string, + id: string, + title: string, + description?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/updatePlaylist`, + { + accountId, + id, + title, + description, + }, + ) + return res + } + + // 删除播放列表 + async deletePlaylist(accountId: string, id: string) { + const res = await this.sendMessage( + `plat/youtube/deletePlaylist`, + { + accountId, + id, + }, + ) + return res + } + + // 插入播放列表项 + async insertPlayListItems( + accountId: string, + id: string, + resourceId: string, + position?: number, + note?: string, + startAt?: string, + endAt?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/insertPlayListItems`, + { + accountId, + id, + resourceId, + position, + note, + startAt, + endAt, + }, + ) + return res + } + + // 获取播放列表项 + async getPlayListItems( + accountId: string, + id?: string, + playlistId?: string, + maxResults?: number, + pageToken?: string, + videoId?: string, + ) { + const res = await this.sendMessage<{ + code: number + message: string + data: any + }>( + `plat/youtube/getPlayListItems`, + { + accountId, + id, + playlistId, + maxResults, + pageToken, + videoId, + }, + ) + return res + } + + // 更新播放列表项 + async updatePlayListItems( + accountId: string, + id: string, + playlistId: string, + resourceId: string, + position?: number, + note?: string, + startAt?: string, + endAt?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/updatePlayListItems`, + { + accountId, + id, + playlistId, + resourceId, + position, + note, + startAt, + endAt, + }, + ) + return res + } + + // 删除播放列表项 + async deletePlayListItems(accountId: string, id: string) { + const res = await this.sendMessage<{ + code: number + message: string + data: any + }>( + `plat/youtube/deletePlayListItems`, + { + accountId, + id, + }, + ) + return res + } + + // 获取频道列表 + async getChannelsList( + accountId: string, + forHandle?: string, + forUsername?: string, + id?: string, + mine?: boolean, + maxResults?: number, + pageToken?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/getChannelsList`, + { + accountId, + forHandle, + forUsername, + id, + mine, + maxResults, + pageToken, + }, + ) + return res + } + + // 获取频道板块列表 + async getChannelsSectionsList( + accountId: string, + channelId?: string, + id?: string, + mine?: boolean, + ) { + const res = await this.sendMessage( + `plat/youtube/getChannelsSectionsList`, + { + accountId, + channelId, + id, + mine, + }, + ) + return res + } + + /** + * YouTube搜索接口 + * @param accountId 账号ID + * @param forMine 是否搜索我的内容 + * @param maxResults 最大结果数 + * @param order 排序方法 + * @param pageToken 分页令牌 + * @param publishedBefore 发布时间之前 + * @param publishedAfter 发布时间之后 + * @param q 搜索查询字词 + * @param type 搜索类型 + * @param videoCategoryId 视频类别ID + * @returns 搜索结果 + */ + async search( + accountId: string, + forMine?: boolean, + maxResults?: number, + order?: string, + pageToken?: string, + publishedBefore?: string, + publishedAfter?: string, + q?: string, + type?: string, + videoCategoryId?: string, + ) { + const res = await this.sendMessage( + `plat/youtube/search`, + { + accountId, + forMine, + maxResults, + order, + pageToken, + publishedBefore, + publishedAfter, + q, + type, + videoCategoryId, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channel.api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channel.api.ts new file mode 100644 index 000000000..973fcbe52 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channel.api.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common' +import { AccountStatus } from '@yikart/mongodb' +import { ChannelBaseApi } from '../channelBase.api' + +@Injectable() +export class ChannelApi extends ChannelBaseApi { + async getUserAccounts(payload: { userId: string }) { + const res = await this.sendMessage( + `platform/${payload.userId}/accounts`, + payload, + ) + return res + } + + async updateChannelAccountStatus(payload: { + accountId: string + status: AccountStatus + }) { + const res = await this.sendMessage( + `platform/accounts/updateStatus`, + payload, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channelApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channelApi.module.ts new file mode 100644 index 000000000..9a664b291 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/channelApi.module.ts @@ -0,0 +1,60 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { PlatBilibiliNatsApi } from './api/bilibili.natsApi' +import { EngagementNatsApi } from './api/engagement/engagement.api' +import { InteractNatsApi } from './api/interact/interact.natsApi' +import { InteractionRecordNatsApi } from './api/interact/interactionRecord.natsApi' +import { ReplyCommentRecordNatsApi } from './api/interact/replyCommentRecord.natsApi' +import { PlatKwaiNatsApi } from './api/kwai.natsApi' +import { PlatMetaNatsApi } from './api/meta.natsApi' +import { PlatPinterestNatsApi } from './api/pinterest.natsApi' +import { PlatPublishNatsApi } from './api/publish.natsApi' +import { PublishTaskNatsApi } from './api/publishTask.natsApi' +import { ChannelSkKeyNatsApi } from './api/skKeyNatsApi.natsApi' +import { PlatTiktokNatsApi } from './api/tiktok.natsApi' +import { PlatTwitterNatsApi } from './api/twitter.natsApi' +import { PlatWxGzhNatsApi } from './api/wxGzh.natsApi' +import { PlatYoutubeNatsApi } from './api/youtube.natsApi' +import { ChannelApi } from './channel.api' + +@Global() +@Module({ + imports: [HttpModule], + providers: [ + ChannelApi, + EngagementNatsApi, + InteractNatsApi, + InteractionRecordNatsApi, + ReplyCommentRecordNatsApi, + PlatBilibiliNatsApi, + PlatKwaiNatsApi, + PlatMetaNatsApi, + PlatPinterestNatsApi, + PlatPublishNatsApi, + PublishTaskNatsApi, + ChannelSkKeyNatsApi, + PlatTiktokNatsApi, + PlatTwitterNatsApi, + PlatWxGzhNatsApi, + PlatYoutubeNatsApi, + ], + exports: [ + ChannelApi, + EngagementNatsApi, + InteractNatsApi, + InteractionRecordNatsApi, + ReplyCommentRecordNatsApi, + PlatBilibiliNatsApi, + PlatKwaiNatsApi, + PlatMetaNatsApi, + PlatPinterestNatsApi, + PlatPublishNatsApi, + PublishTaskNatsApi, + ChannelSkKeyNatsApi, + PlatTiktokNatsApi, + PlatTwitterNatsApi, + PlatWxGzhNatsApi, + PlatYoutubeNatsApi, + ], +}) +export class ChannelApiModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/common.ts new file mode 100644 index 000000000..f58646c07 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channel/common.ts @@ -0,0 +1,46 @@ +import { AccountType } from '@yikart/common' + +export enum PublishType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', +} + +export enum PublishStatus { + FAILED = -1, // 发布失败 + WaitingForPublish = 0, // 未发布 + PUBLISHED = 1, // 已发布 + PUBLISHING = 2, // 发布中 +} + +export interface PublishRecord { + id: string + userId: string + flowId?: string // 前端传入的流水ID + userTaskId?: string // 用户任务ID + type: PublishType + title?: string + desc?: string // 主要内容 + accountId: string + topics: string[] + accountType: AccountType + uid: string + videoUrl?: string + coverUrl?: string + imgUrlList?: string[] + publishTime: Date + status: PublishStatus + queueId?: string + inQueue: boolean + errorMsg?: string + option?: any + dataId: string // 微信公众号-publish_id + workLink?: string // 作品链接 + dataOption?: Record + createdAt: Date + updatedAt: Date +} + +export enum PublishingChannel { + INTERNAL = 'internal', // 通过我们内部系统发布的 + NATIVE = 'native', // 平台原生端发布的 +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channelBase.api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channelBase.api.ts new file mode 100644 index 000000000..af8a83364 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/channelBase.api.ts @@ -0,0 +1,24 @@ +import { HttpService } from '@nestjs/axios' +import { Injectable, Logger } from '@nestjs/common' +import { AppException } from '@yikart/common' +import axios from 'axios' +import { config } from '../config' + +@Injectable() +export class ChannelBaseApi { + private readonly logger = new Logger(ChannelBaseApi.name) + constructor(private readonly httpService: HttpService) { } + async sendMessage(path: string, body: any): Promise { + const res = await axios.post<{ + code: number + message: string + data: T + timestamp: number + }>(`${config.channelApi.baseUrl}/${path}`, body) + if (res.data.code !== 0) { + this.logger.error({ path, ...res }) + throw new AppException(res.data.code, res.data.message) + } + return res.data.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/comment.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/comment.ts new file mode 100644 index 000000000..4c0936850 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/comment.ts @@ -0,0 +1,5 @@ +export interface NatsRes { + code: number + data: T + message: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/common.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/common.ts new file mode 100644 index 000000000..3ad5f8f43 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/common.ts @@ -0,0 +1,117 @@ +import { AccountType } from '@yikart/common' + +export enum TaskType { + VIDEO = 'video', + ARTICLE = 'article', + PROMOTION = 'promotion', + INTERACTION = 'interaction', +} +export enum UserTaskStatus { + DOING = 'doing', // 进行中 + PENDING = 'pending', // 待提现奖励 + APPROVED = 'approved', // 已通过(完成) + REJECTED = 'rejected', // 已拒绝 + CANCELLED = 'cancelled', // 已取消 + DEL = 'del', // 已删除或回退 +} + +export interface UserTask { + _id: string + id: string + userId: string + taskId: string + opportunityId?: string // 派发记录ID + accountId: string + accountType: AccountType + uid: string + status: UserTaskStatus + keepTime: number // 保持时间(秒) + submissionUrl?: string // 提交的视频、文章或截图URL + submissionTime?: Date // 提交时间 + completionTime?: Date // 完成时间 + rejectionReason?: string // 拒绝原因 + metadata?: Record // 额外信息,如审核反馈等 + isFirstTimeSubmission: boolean // 是否首次提交,用于确定是否给予首次奖励 + verifierUserId?: string // 核查人员ID + verificationNote?: string // 人工核查备注 + reward: number // 奖励金额 + rewardTime?: Date // 奖励发放时间 + taskMaterialId?: string // 任务的素材ID + screenshotUrls?: string[] // 任务完成截图 + createdAt: Date + updatedAt: Date +} + +export enum TaskOpportunityStatus { + PENDING = 'pending', // 待接取 + ACCEPTED = 'accepted', // 已接取 + EXPIRED = 'expired', // 已过期 +} + +export interface TaskOpportunity { + _id: string + id: string + taskId: string + reward?: number + accountId?: string + nickname?: string + userId: string + userName?: string + mail?: string + accountType?: AccountType + accountTypes?: AccountType[] + uid?: string + status: TaskOpportunityStatus + isView?: boolean + expiredAt: Date + metadata?: Record // 额外信息,如匹配得分等 + createdAt: Date + updatedAt: Date +} + +export interface UserPortraitReportData { + userId: string + name?: string + avatar?: string + status?: number + lastLoginTime?: Date + contentTags?: Record + totalFollowers?: number + totalWorks?: number + totalViews?: number + totalLikes?: number + totalCollects?: number +} + +export enum TaskStatus { + ACTIVE = 'active', + CANCELLED = 'cancelled', + DEL = 'del', +} + +export class InteractionTaskData { + type: string + targetWorksId: string + targetAuthorId?: string + platform?: string +} + +export class Task { + id: string + title: string + description: string + type: TaskType + maxRecruits: number + currentRecruits: number + deadline: Date + reward: number + status: TaskStatus + accountTypes: AccountType[] + taskData?: InteractionTaskData + materialIds: string[] + materialGroupId?: string // 草稿箱ID + autoDeleteMaterial?: boolean + autoDispatch?: boolean // 是否自动派发 用户创建时 + createdAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.interface.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.interface.ts new file mode 100644 index 000000000..452bccde1 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.interface.ts @@ -0,0 +1,43 @@ +export interface TaskDetail { + id: string + title: string + description: string + type: string + maxRecruits: number + currentRecruits: number + deadline: string + reward: number + status: string + accountTypes: string[] + taskData?: { + targetWorksId?: string + targetAuthorId?: string + platform?: string + } + createdAt: string + updatedAt: string +} + +export interface TaskWithOpportunityDetail extends TaskDetail { + opportunityId: string + opportunityStatus: string + expiredAt: string + accountId: string +} + +export interface TotalAmountResult { + totalAmount: number +} + +export interface UserTaskDetail { + id: string + taskId: string + userId: string + status: string + accountType: string + uid: string + account: string + accountId: string + reward: number + createdAt: string +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.natsApi.ts new file mode 100644 index 000000000..d82d554fb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/task.natsApi.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common' +import { TaskBaseApi } from '../../taskBase.api' +import { Task } from './common' + +@Injectable() +export class TaskNatsApi extends TaskBaseApi { + async getTaskInfo(taskId: string) { + const res = await this.sendMessage( + `task/task/info`, + { + id: taskId, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/taskApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/taskApi.module.ts new file mode 100644 index 000000000..e1b72f95b --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/taskApi.module.ts @@ -0,0 +1,12 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { TaskNatsApi } from './task.natsApi' +import { UserTaskNatsApi } from './user-task.natsApi' + +@Global() +@Module({ + imports: [HttpModule], + providers: [UserTaskNatsApi, TaskNatsApi], + exports: [UserTaskNatsApi, TaskNatsApi], +}) +export class TaskApiModule {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/user-task.natsApi.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/user-task.natsApi.ts new file mode 100644 index 000000000..ffebd5d52 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/api/user-task.natsApi.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common' +import { TaskBaseApi } from '../../taskBase.api' +import { UserTask } from './common' + +@Injectable() +export class UserTaskNatsApi extends TaskBaseApi { + /** + * 获取用户任务信息 + * @param id 任务id + * @returns 信息 + */ + async getUserTaskInfo(id: string) { + const res = await this.sendMessage( + `task/userTask/info`, + { + id, + }, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/taskApi.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/taskApi.module.ts new file mode 100644 index 000000000..e815466ad --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/task/taskApi.module.ts @@ -0,0 +1,12 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { TaskNatsApi } from './api/task.natsApi' +import { UserTaskNatsApi } from './api/user-task.natsApi' + +@Global() +@Module({ + imports: [HttpModule], + providers: [UserTaskNatsApi, TaskNatsApi], + exports: [UserTaskNatsApi, TaskNatsApi], +}) +export class TaskApiModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/taskBase.api.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/taskBase.api.ts new file mode 100644 index 000000000..22191d6a0 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/taskBase.api.ts @@ -0,0 +1,24 @@ +import { HttpService } from '@nestjs/axios' +import { Injectable, Logger } from '@nestjs/common' +import { AppException } from '@yikart/common' +import axios from 'axios' +import { config } from '../config' + +@Injectable() +export class TaskBaseApi { + private readonly logger = new Logger(TaskBaseApi.name) + constructor(private readonly httpService: HttpService) { } + async sendMessage(path: string, body: any): Promise { + const res = await axios.post<{ + code: number + message: string + data: T + timestamp: number + }>(`${config.taskApi.baseUrl}/${path}`, body) + if (res.data.code !== 0) { + this.logger.error({ path, ...res }) + throw new AppException(res.data.code, res.data.message) + } + return res.data.data + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/transports.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/transports.module.ts new file mode 100644 index 000000000..ab469e100 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/transports/transports.module.ts @@ -0,0 +1,18 @@ +import { HttpModule } from '@nestjs/axios' +import { Global, Module } from '@nestjs/common' +import { ChannelApiModule } from './channel/channelApi.module' +import { ChannelBaseApi } from './channelBase.api' +import { TaskApiModule } from './task/taskApi.module' +import { TaskBaseApi } from './taskBase.api' + +@Global() +@Module({ + imports: [HttpModule, ChannelApiModule, TaskApiModule], + providers: [ + ChannelBaseApi, + TaskBaseApi, + ], + exports: [ + ], +}) +export class TransportsModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/types/mime-types.d.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/types/mime-types.d.ts new file mode 100644 index 000000000..cd0a74e44 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/types/mime-types.d.ts @@ -0,0 +1,9 @@ +declare module 'mime-types' { + export function lookup(path: string): string | false + export function contentType(path: string): string | false + export function extension(type: string): string | false + export function charset(type: string): string | false + + export const types: { [key: string]: string } + export const extensions: { [key: string]: string[] } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/types/xml2js.d.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/types/xml2js.d.ts new file mode 100644 index 000000000..f4c1f4d02 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/types/xml2js.d.ts @@ -0,0 +1,12 @@ +declare module 'xml2js' { + export function parseString( + str: string, + options: { explicitArray: boolean }, + callback: (err: any, result: any) => void, + ): void + + export function parseString( + str: string, + callback: (err: any, result: any) => void, + ): void +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/class/user.class.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/class/user.class.ts new file mode 100644 index 000000000..cdd49d109 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/class/user.class.ts @@ -0,0 +1,25 @@ +import { User } from '@yikart/mongodb' +import { getRandomString } from '../../common/utils' + +export enum UserCreateType { + mail = 'mail', + google = 'google', +} +export class NewUser extends User { + static createType: UserCreateType + constructor(type: UserCreateType, mail: string, option: { password: string, salt: string }) + constructor(type: UserCreateType, mail: string, googleAccount: User['googleAccount']) + constructor(type: UserCreateType, mail: string, params: { password: string, salt: string } | User['googleAccount']) { + super() + this.mail = mail + this.name = `user_${getRandomString(8)}` + + if (type === UserCreateType.mail) { + const mailParams = params as { password: string, salt: string } + this.password = mailParams.password + this.salt = mailParams.salt + } + if (type === UserCreateType.google) + this.googleAccount = params as User['googleAccount'] + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/login.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/login.dto.ts new file mode 100644 index 000000000..f410738cc --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/login.dto.ts @@ -0,0 +1,47 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: nevin + * @Description: 用户 + */ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +const MailLoginSchema = z.object({ + mail: z.string().email({ message: '邮箱' }), + password: z.string({ message: '密码' }), +}) + +export class MailLoginDto extends createZodDto(MailLoginSchema) {} + +const MailRegistUrlSchema = z.object({ + mail: z.string().email({ message: '邮箱' }), +}) +export class MailRegistUrlDto extends createZodDto(MailRegistUrlSchema) {} + +export const RegistByMailSchema = z.object({ + mail: z.string().email().describe('邮箱'), + code: z.string().describe('验证码'), + password: z.string().describe('密码'), + inviteCode: z.string().describe('邀请码').optional(), +}) +export class RegistByMailDto extends createZodDto(RegistByMailSchema) { } + +const MailRepasswordSchema = z.object({ + mail: z.string().email({ message: '邮箱' }), +}) +export class MailRepasswordDto extends createZodDto(MailRepasswordSchema) {} + +const GoogleLoginSchema = z.object({ + clientId: z.string({ message: 'Google客户端ID' }), + credential: z.string({ message: 'Google认证凭证' }), +}) + +export class GoogleLoginDto extends createZodDto(GoogleLoginSchema) {} + +const UserCancelSchema = z.object({ + code: z.string({ message: '验证码' }), +}) + +export class UserCancelDto extends createZodDto(UserCancelSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/points.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/points.dto.ts new file mode 100644 index 000000000..99435bc7f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/points.dto.ts @@ -0,0 +1,40 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +// 添加积分的 DTO +export const addPointsSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + amount: z.number().min(0).describe('积分数量'), + type: z.string().min(1).describe('积分类型'), + description: z.string().optional().describe('积分描述'), + metadata: z.record(z.string(), z.any()).optional().describe('额外信息'), +}) + +export class AddPointsDto extends createZodDto(addPointsSchema) {} + +// 扣减积分的 DTO +export const deductPointsSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + amount: z.number().min(0).describe('积分数量'), + type: z.string().min(1).describe('积分类型'), + description: z.string().optional().describe('积分描述'), + metadata: z.record(z.string(), z.any()).optional().describe('额外信息'), +}) + +export class DeductPointsDto extends createZodDto(deductPointsSchema) {} + +// 查询用户积分余额的 DTO +export const pointsBalanceSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), +}) + +export class PointsBalanceDto extends createZodDto(pointsBalanceSchema) {} + +// 查询积分记录列表的 DTO +export const pointsRecordsSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + page: z.int().min(1).describe('页码'), + pageSize: z.int().min(1).describe('每页数量'), +}) + +export class PointsRecordsDto extends createZodDto(pointsRecordsSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/storage.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/storage.dto.ts new file mode 100644 index 000000000..6251762c2 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/storage.dto.ts @@ -0,0 +1,34 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +// 增加已用存储的 DTO +export const addUsedStorageSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + amount: z.number().min(0).describe('存储大小(Bytes)'), +}) + +export class AddUsedStorageDto extends createZodDto(addUsedStorageSchema) {} + +// 减少已用存储的 DTO +export const deductUsedStorageSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + amount: z.number().min(0).describe('存储大小(Bytes)'), +}) + +export class DeductUsedStorageDto extends createZodDto(deductUsedStorageSchema) {} + +// 设置总存储容量的 DTO +export const setTotalStorageSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), + totalStorage: z.number().min(0).describe('总存储容量(Bytes)'), + expiredAt: z.date().optional().describe('过期时间'), +}) + +export class SetTotalStorageDto extends createZodDto(setTotalStorageSchema) {} + +// 查询用户存储信息的 DTO +export const storageInfoSchema = z.object({ + userId: z.string().min(1).describe('用户ID'), +}) + +export class StorageInfoDto extends createZodDto(storageInfoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.dto.ts new file mode 100644 index 000000000..cc4be5473 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.dto.ts @@ -0,0 +1,25 @@ +import { createZodDto } from '@yikart/common' +import { GenderEnum } from '@yikart/mongodb' +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: nevin + * @Description: 用户 + */ +import { z } from 'zod' + +const ChangePasswordSchema = z.object({ + password: z.string({ message: '密码' }), +}) + +export class ChangePasswordDto extends createZodDto(ChangePasswordSchema) {} + +const UpdateUserInfoSchema = z.object({ + name: z.string({ message: '昵称' }).optional(), + avatar: z.string({ message: '头像' }).optional(), + gender: z.nativeEnum(GenderEnum, { message: '性别' }).optional(), + desc: z.string({ message: '简介' }).optional(), +}) + +export class UpdateUserInfoDto extends createZodDto(UpdateUserInfoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.vo.ts new file mode 100644 index 000000000..078c30289 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/user.vo.ts @@ -0,0 +1,47 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-05-06 15:49:03 + * @LastEditors: nevin + * @Description: 用户 + */ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +const UserInfoSchema = z.object({ + id: z.string().min(1).max(50).describe('用户ID'), + name: z.string().min(1).max(50).describe('用户名'), + mail: z.string().email().optional().describe('邮箱'), + phone: z.string().min(1).max(20).optional().describe('手机号'), + status: z.number().describe('用户状态,0-禁用,1-启用'), + isDelete: z.boolean().describe('是否删除'), + popularizeCode: z.string().min(1).max(20).optional().describe('我的推广码'), + inviteUserId: z.string().min(1).max(50).optional().describe('邀请人用户ID'), + inviteCode: z.string().min(1).max(20).optional().describe('我填写的邀请码'), + score: z.number().describe('积分字段'), + googleAccount: z + .object({ + googleId: z.string().min(1).max(50).describe('谷歌ID'), + email: z.string().email().describe('谷歌邮箱'), + }) + .optional() + .describe('谷歌账号信息'), + vipInfo: z + .object({ + expireTime: z.date().describe('会员过期时间'), + status: z.number().describe('会员状态: none-无会员 trialing-试用中 monthly_once-一次性月会员 annual_once-一次性年会员 active_monthly-连续包月 active_yearly-连续包年 active_nonrenewing-有效未续购 expired-到期'), + startTime: z.date().describe('会员开始时间'), + }) + .optional() + .describe('会员信息'), + earnInfo: z + .object({ + totalEarn: z.number().describe('总收益'), + todayEarn: z.number().describe('今日收益'), + yesterdayEarn: z.number().describe('昨日收益'), + }) + .optional() + .describe('收益信息'), + +}) +export class UserInfoVO extends createZodDto(UserInfoSchema) { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/userWalletAccount.dto.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/userWalletAccount.dto.ts new file mode 100644 index 000000000..db91a756d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/dto/userWalletAccount.dto.ts @@ -0,0 +1,30 @@ +import { createZodDto } from '@yikart/common' +import { WalletAccountType } from '@yikart/mongodb' +import z from 'zod' + +export const createUserWalletAccountSchema = z.object({ + mail: z.email().optional().describe('邮箱'), + userName: z.string().describe('真实姓名').optional(), + account: z.string().describe('账号'), + cardNum: z.string().describe('身份证号').optional(), + phone: z.string().describe('绑定的手机号').optional(), + type: z.nativeEnum(WalletAccountType).describe('类型'), +}) +export class CreateUserWalletAccountDto extends createZodDto(createUserWalletAccountSchema) {} + +export const updateUserWalletAccountSchema = z.object({ + ...createUserWalletAccountSchema.shape, + id: z.string().describe('ID'), +}) +export class UpdateUserWalletAccountDto extends createZodDto(updateUserWalletAccountSchema) {} + +export const userWalletAccountIdSchema = z.object({ + ...createUserWalletAccountSchema.shape, + id: z.string().describe('ID'), +}) +export class UserWalletAccountIdDto extends createZodDto(userWalletAccountIdSchema) {} + +export const incomeRecordListFilterSchema = z.object({ + userId: z.string().optional(), +}) +export class UserWalletAccountListFilterDto extends createZodDto(incomeRecordListFilterSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/login.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/login.controller.ts new file mode 100644 index 000000000..0898b3452 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/login.controller.ts @@ -0,0 +1,334 @@ +import { Body, Controller, Delete, Get, Logger, Post, Put } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { AitoearnAuthService, GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode } from '@yikart/common' +import { MailService } from '@yikart/mail' +import { UserStatus } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import { getRandomString } from '../common/utils' +import { encryptPassword, validatePassWord } from '../common/utils/password.util' +import { config } from '../config' +import { + GoogleLoginDto, + MailLoginDto, + MailRepasswordDto, + RegistByMailDto, + UserCancelDto, +} from './dto/login.dto' +import { UserService } from './user.service' + +interface UserMailRegistCache { + code: string + status: 0 | 1 +} + +@ApiTags('用户登录-login') +@Controller('login') +export class LoginController { + private readonly logger = new Logger(LoginController.name) + constructor( + private readonly authService: AitoearnAuthService, + private readonly userService: UserService, + private readonly redisService: RedisService, + private readonly mailService: MailService, + ) { } + + @ApiOperation({ + summary: '邮箱登陆/注册', + description: '邮箱登陆/注册', + }) + @Public() + @Post('mail') + async loginByMail(@Body() loginInfo: MailLoginDto) { + const { mail, password } = loginInfo + + const userInfo = await this.userService.getUserInfoByMail(mail, true) + if (!!userInfo && !userInfo.isDelete) { + if (userInfo.status === UserStatus.STOP) + throw new AppException(ResponseCode.UserStatusError, 'The account has been disabled') + + // 校验密码 + const isOk = validatePassWord( + userInfo.password || '', + userInfo.salt || '', + password, + ) + + if (!isOk) + throw new AppException(ResponseCode.UserPasswordError, 'password is error') + const token = this.authService.generateToken(userInfo) + const TokenInfo = this.authService.decodeToken(token) + + this.userService.afterLogin(userInfo) + + return { + type: 'login', + token, + exp: TokenInfo.exp, + userInfo, + } + } + + // 没有进行创建逻辑 + const code = getRandomString(6, true) + const mailRes = await this.mailService.sendEmail({ + to: mail, + subject: 'aitoearn regist', + template: 'mail/regist', + context: { + code, + mail, + }, + }) + + if (!mailRes) + throw new AppException(ResponseCode.MailSendFail, 'Mail sending failed') + + await this.redisService.setJson( + `userMailRegist:${mail}`, + { + code, + status: 0, + }, + 60 * 10, + ) + + return { + type: 'regist', + code: config.environment === 'production' ? '' : code, + } + } + + @ApiOperation({ + summary: '邮箱注册', + description: '邮箱注册', + }) + @Public() + @Post('mail/regist') + async registByMail(@Body() body: RegistByMailDto) { + const { mail, code, inviteCode, password } = body + const rData = await this.redisService.getJson( + `userMailRegist:${mail}`, + ) + if (!rData) + throw new AppException(ResponseCode.UserLoginCodeError, 'The verification code does not exist') + + // config.environment === 'production' && + if (rData.code !== code) + throw new AppException(ResponseCode.UserLoginCodeError, 'The verification code is incorrect') + + if (inviteCode) { + const inviteUserInfo + = await this.userService.getUserByPopularizeCode(inviteCode) + if (!inviteUserInfo) + throw new AppException(ResponseCode.UserLoginCodeError, 'The invitation code is incorrect') + } + + // 生成加盐密码 + const { password: resPassword, salt: resSalt } = encryptPassword(password) + + // 创建新用户 + const userInfo = await this.userService.createUserByMail( + mail, + resPassword, + resSalt, + inviteCode, + ) + + const token = this.authService.generateToken(userInfo) + const TokenInfo = this.authService.decodeToken(token) + + return { + token, + exp: TokenInfo.exp, + userInfo, + } + } + + @ApiOperation({ + summary: '邮箱重置密码', + description: '邮箱重置密码', + }) + @Public() + @Post('repassword/mail') + async repasswordByMail(@Body() body: MailRepasswordDto) { + const { mail } = body + + const userInfo = await this.userService.getUserInfoByMail(mail) + + if (!userInfo || userInfo.isDelete) + throw new AppException(ResponseCode.UserStatusError, 'The account does not exist') + + // 没有进行创建逻辑 + const code = getRandomString(6, true) + const rRes = await this.redisService.setJson( + `userMailRepassword:${mail}`, + { code, status: 0 }, + 60 * 5, + ) + if (!rRes) + throw new AppException(ResponseCode.MailSendFail, 'Mail code add failed') + + // 发验证码邮件,邮箱号和code + const mailRes = await this.mailService.sendEmail({ + to: mail, + subject: 'aitoearn repassword', + template: 'mail/repassword', + context: { + code, + mail, + }, + }) + if (!mailRes) + throw new AppException(ResponseCode.MailSendFail, 'Mail sending failed') + + return config.environment === 'production' ? '' : code + } + + @ApiOperation({ + summary: '邮箱重设密码', + description: '邮箱重设密码', + }) + @Public() + @Put('repassword/mail') + async getRepasswordByMailBack(@Body() body: RegistByMailDto) { + const { mail, code, password } = body + + const rRes = await this.redisService.getJson( + `userMailRepassword:${mail}`, + ) + if (!rRes || rRes.code !== code) + throw new AppException(ResponseCode.ValidationFailed, 'The verification code is incorrect') + + const userInfo = await this.userService.getUserInfoByMail(mail) + if (!userInfo || userInfo.isDelete) + throw new AppException(ResponseCode.UserStatusError, 'The account does not exist') + + const { password: resPassword, salt: resSalt } = encryptPassword(password) + + const res = await this.userService.updateUserPassword( + userInfo.id, + resPassword, + resSalt, + ) + + if (!res) + throw new AppException(ResponseCode.ValidationFailed, 'Password update failed') + + const token = this.authService.generateToken(userInfo) + const TokenInfo = this.authService.decodeToken(token) + + return { + token, + exp: TokenInfo.exp, + userInfo, + } + } + + @ApiOperation({ + summary: 'google登录', + description: 'google登录', + }) + @Public() + @Post('google') + async loginByGoogle(@Body() loginInfo: GoogleLoginDto) { + const { clientId, credential } = loginInfo + const userInfo = await this.userService.getUserInfoByGoogle( + clientId, + credential, + ) + if (!userInfo) + throw new AppException(ResponseCode.UserNotFound, 'The User does not exist') + + if (userInfo.status === UserStatus.STOP) + throw new AppException(ResponseCode.UserStatusError, 'The User is disabled') + const tokenInfo = { + id: userInfo.id, + mail: userInfo.mail, + name: userInfo.name, + } + const token = this.authService.generateToken(tokenInfo) + const TokenInfo = this.authService.decodeToken(token) + + this.userService.afterLogin(userInfo) + + return { + type: 'login', + token, + exp: TokenInfo.exp, + userInfo, + } + } + + @ApiOperation({ + summary: '获取注销验证码', + description: '获取注销验证码', + }) + @Get('cancel/code') + async getCancelMailCode(@GetToken() token: TokenInfo) { + const userInfo = await this.userService.getUserInfoById(token.id) + if (!userInfo || !userInfo.isDelete) + throw new AppException(ResponseCode.UserStatusError) + + const code = getRandomString(6, true) + + // 发验证码邮件,邮箱号和code + const mailRes = await this.mailService.sendEmail({ + to: userInfo.mail, + subject: 'aitoearn cancel', + template: 'mail/cancel', + context: { + code, + }, + }) + + void this.redisService.set( + `userCancelCode:${userInfo.mail}`, + code, + 60 * 5, + ) + + return mailRes + } + + @ApiOperation({ + summary: '注销', + description: '注销', + }) + @Delete('cancel') + async cancelByMail( + @GetToken() token: TokenInfo, + @Body() body: UserCancelDto, + ) { + const { code } = body + const cacheCode = await this.redisService.get( + `userCancelCode:${token.mail}`, + ) + if (cacheCode !== code) + throw new AppException(ResponseCode.ValidationFailed, 'The verification code does not exist') + + const res = await this.userService.delete(token.id) + return res + } + + @ApiOperation({ + summary: 'google登录注销', + description: 'google登录注销', + }) + @Post('cancel/google') + async cancelByGoogle(@GetToken() payload: TokenInfo, @Body() loginInfo: GoogleLoginDto) { + const { clientId, credential } = loginInfo + + const tokenInfo = { + id: payload.id, + mail: payload.mail, + name: payload.name, + } + const token = this.authService.generateToken(tokenInfo) + const cancelRes = this.userService.cancelLoginByGoogle(clientId, credential, token) + this.logger.debug(cancelRes) + + const res = await this.userService.delete(tokenInfo.id) + return res + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/points.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/points.service.ts new file mode 100644 index 000000000..fc6fd57ef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/points.service.ts @@ -0,0 +1,213 @@ +import { Injectable, Logger } from '@nestjs/common' +import { Cron, CronExpression } from '@nestjs/schedule' +import { AppException, ResponseCode } from '@yikart/common' +import { PointsRecordRepository, User, UserRepository, UserStatus } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import dayjs from 'dayjs' +import * as _ from 'lodash' +import { AddPointsDto, DeductPointsDto } from './dto/points.dto' + +@Injectable() +export class PointsService { + private readonly logger = new Logger(PointsService.name) + + constructor( + private readonly redisService: RedisService, + private readonly userRepository: UserRepository, + private readonly pointsRecordRepository: PointsRecordRepository, + ) { } + + /** + * 获取用户积分余额 + * @param userId 用户ID + * @returns 用户积分余额 + */ + async getBalance(userId: string): Promise { + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound, 'User not found') + } + return user.score || 0 + } + + /** + * 获取积分记录列表 + * @param userId 用户ID + * @param page 每页数量 + * @param pageSize 偏移量 + * @returns 积分记录列表 + */ + async getRecords(userId: string, page = 1, pageSize = 10) { + const [list, total] = await this.pointsRecordRepository.listWithPagination({ + userId, + page, + pageSize, + }) + return { list, total } + } + + /** + * 获取本月vip增加积分记录 + * @param userId 用户ID + * @returns + */ + async findVipOpintsAddRepordOfMonth(userId: string) { + return await this.pointsRecordRepository.findVipOpintsAddRepordOfMonth(userId) + } + + /** + * 增加积分 + * @param data 添加积分的数据 + */ + async addPoints(data: AddPointsDto): Promise { + const { userId, amount, type, description, metadata } = data + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound, 'User not found') + } + + await this.pointsRecordRepository.addPoints(user, { + amount, + type, + description, + metadata, + }) + this.redisService.del(`UserInfo:${userId}`) + } + + /** + * 扣减积分 + * @param data 扣减积分的数据 + */ + async deductPoints(data: DeductPointsDto): Promise { + const { userId, amount, type, description, metadata } = data + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound, 'User not found') + } + + // 检查用户积分余额是否充足 + if (user.score < amount) { + throw new AppException(ResponseCode.UserInsufficientBalance, 'Insufficient balance') + } + + await this.pointsRecordRepository.deductPoints(user, { + amount, + type, + description, + metadata, + }) + this.redisService.del(`UserInfo:${userId}`) + } + + // 每天凌晨1点检查每天用户变动的用户里,找出所有的花费日志行为,标记为抵扣,计算出来总数 + @Cron(CronExpression.EVERY_DAY_AT_1AM) + async dailyDiKouCostCheck() { + const lockKey = 'points:daily:cost:deduction:lock' + const lockValue = await this.redisService.get(lockKey) + + if (lockValue) { + this.logger.warn('Daily cost deduction already processed today') + return + } + + // 设置锁,24小时过期 + await this.redisService.set(lockKey, '1', 60 * 60 * 24) + + try { + const updatedAt = { + $lt: dayjs().startOf('day').valueOf(), + $gte: dayjs().startOf('day').subtract(1, 'd').valueOf(), + } + const condition = { status: UserStatus.OPEN, updatedAt } + + let processedUsers = 0 + let failedUsers = 0 + + const cursor = this.userRepository.getCursor(condition, 'id status updatedAt') + + for (let user = await cursor.next(); user !== null; user = await cursor.next()) { + if (_.isEmpty(user)) + continue + + try { + await this.diKouCostByUser(user, updatedAt) + processedUsers++ + } + catch (error) { + failedUsers++ + this.logger.error(`Failed to process cost deduction for user ${user.id}`, error) + } + } + + this.logger.log(`Daily cost deduction completed. Processed: ${processedUsers}, Failed: ${failedUsers}`) + } + catch (error) { + // 删除锁,允许重试 + await this.redisService.del(lockKey) + throw error + } + } + + async diKouCostByUser(user: User, updatedAt: any) { + return await this.pointsRecordRepository.diKouCostByUser(user, updatedAt) + } + + // 每天检查每位用户的积分过期情况 + @Cron(CronExpression.EVERY_DAY_AT_3AM) + async checkPointExpiration() { + const lockKey = 'points:daily:expiration:check:lock' + const lockValue = await this.redisService.get(lockKey) + + if (lockValue) { + this.logger.warn('Daily point expiration check already processed today') + return + } + + // 设置锁,24小时过期 + await this.redisService.set(lockKey, '1', 60 * 60 * 24) + + try { + this.logger.log('Starting daily point expiration check') + + let processedUsers = 0 + let failedUsers = 0 + + const cursor = this.userRepository.getCursor({ status: UserStatus.OPEN, score: { $gt: 0 } }, 'id status updatedAt') + + for (let user = await cursor.next(); user !== null; user = await cursor.next()) { + if (_.isEmpty(user)) + continue + + try { + await Promise.all([ + this.getPointBySub(user), + this.getPoint10DayExp(user), + ]) + processedUsers++ + } + catch (error) { + failedUsers++ + this.logger.error(`Failed to process point expiration for user ${user.id}`, error) + } + } + + this.logger.log(`Daily point expiration check completed. Processed: ${processedUsers}, Failed: ${failedUsers}`) + } + catch (error) { + this.logger.error('Daily point expiration check failed', error) + // 删除锁,允许重试 + await this.redisService.del(lockKey) + throw error + } + } + + async getPointBySub(user: User) { + await this.pointsRecordRepository.getPointBySub(user) + this.redisService.del(`UserInfo:${user.id}`) + } + + async getPoint10DayExp(user: User) { + await this.pointsRecordRepository.getPoint10DayExp(user) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/storage.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/storage.service.ts new file mode 100644 index 000000000..d66a3e30a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/storage.service.ts @@ -0,0 +1,100 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { UserRepository } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import { AddUsedStorageDto, DeductUsedStorageDto } from './dto/storage.dto' + +@Injectable() +export class StorageService { + private readonly logger = new Logger(StorageService.name) + + constructor( + private readonly redisService: RedisService, + private readonly userRepository: UserRepository, + ) { } + + /** + * 获取用户存储使用情况 + * @param userId 用户ID + * @returns 用户存储信息 + */ + async getStorageInfo(userId: string) { + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound) + } + + const used = user.usedStorage || 0 + const total = user.storage?.total || 0 + const available = Math.max(0, total - used) + + return { + used, + total, + available, + } + } + + /** + * 增加已用存储 + * @param data 增加存储数据 + */ + async addUsedStorage(data: AddUsedStorageDto): Promise { + const user = await this.userRepository.getById(data.userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound) + } + + const currentUsed = user.usedStorage || 0 + const totalStorage = user.storage?.total || 0 + const newUsedStorage = currentUsed + data.amount + + if (newUsedStorage > totalStorage) { + throw new AppException(ResponseCode.UserStorageExceeded) + } + + await this.userRepository.updateById( + data.userId, + { $set: { usedStorage: newUsedStorage } }, + ) + // 清除缓存 + this.redisService.del(`UserInfo:${data.userId}`) + } + + /** + * 减少已用存储 + * @param data 减少存储数据 + */ + async deductUsedStorage(data: DeductUsedStorageDto): Promise { + const user = await this.userRepository.getById(data.userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound) + } + + const currentUsed = user.usedStorage || 0 + const newUsedStorage = Math.max(0, currentUsed - data.amount) + + await this.userRepository.updateById( + data.userId, + { $set: { usedStorage: newUsedStorage } }, + ) + this.redisService.del(`UserInfo:${data.userId}`) + } + + /** + * 设置总存储容量 + * @param userId 用户ID + * @param totalStorage 总存储容量(Bytes) + * @param expiredAt 过期时间(可选) + */ + async setTotalStorage(userId: string, totalStorage: number, expiredAt?: Date): Promise { + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound) + } + + await this.userRepository.setTotalStorage(userId, totalStorage, expiredAt) + + this.redisService.del(`UserInfo:${userId}`) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.controller.ts new file mode 100644 index 000000000..266166027 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common' +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger' +import { GetToken, Public, TokenInfo } from '@yikart/aitoearn-auth' +import { AppException, ResponseCode, TableDto } from '@yikart/common' +import { UpdateUserInfoDto } from './dto/user.dto' +import { UserInfoVO } from './dto/user.vo' +import { UserService } from './user.service' + +@ApiTags('用户') +@Controller('user') +export class UserController { + constructor(private readonly userService: UserService) { } + + @ApiOperation({ + summary: '用户信息', + description: '用户信息', + }) + @Public() + @ApiResponse({ status: 200, type: UserInfoVO }) + @Get('info/mail/:mail') + async infoByMail(@Param('mail') mail: string) { + const res = await this.userService.getUserInfoByMail(mail) + return res + } + + @ApiOperation({ + description: '获取自己的用户信息', + summary: '获取自己的用户信息', + }) + @ApiResponse({ status: 200, type: UserInfoVO }) + @Get('mine') + getUserInfoById(@GetToken() token: TokenInfo) { + return this.userService.getUserInfoById(token.id) + } + + @ApiOperation({ description: '更新用户信息', summary: '更新用户信息' }) + @Put('info/update') + async updateInfo( + @GetToken() token: TokenInfo, + @Body() body: UpdateUserInfoDto, + ) { + const userInfo = await this.userService.getUserInfoById(token.id) + if (!userInfo) + throw new AppException(ResponseCode.UserNotFound, 'User not found') + + const res = await this.userService.updateUserInfo(token.id, body) + return res + } + + // 积分相关 + @ApiOperation({ summary: '获取我的积分记录' }) + @Get('points/records') + getMyPointsRecords( + @GetToken() token: TokenInfo, + @Query() query: TableDto, + ) { + return this.userService.getMyPointsRecords(token.id, query.pageNo, query.pageSize) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.internal.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.internal.controller.ts new file mode 100644 index 000000000..bf2828920 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.internal.controller.ts @@ -0,0 +1,13 @@ +import { Body, Controller, Post } from '@nestjs/common' +import { Internal } from '@yikart/aitoearn-auth' +import { UserService } from './user.service' + +@Controller() +@Internal() +export class UserInternalController { + constructor(private readonly userService: UserService) { } + @Post('userInternal/user/info') + getUserInfoById(@Body() body: { id: string }) { + return this.userService.getUserInfoById(body.id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.module.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.module.ts new file mode 100644 index 000000000..e550bc00f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.module.ts @@ -0,0 +1,19 @@ +import { Global, Module } from '@nestjs/common' +import { LoginController } from './login.controller' +import { PointsService } from './points.service' +import { StorageService } from './storage.service' +import { UserController } from './user.controller' +import { UserService } from './user.service' +import { UserPopController } from './userPop.controller' +import { UserWalletAccountController } from './userWalletAccount.controller' +import { UserWalletAccountService } from './userWalletAccount.service' +import { VipController } from './vip.controller' +import { VipService } from './vip.service' + +@Global() +@Module({ + controllers: [UserController, LoginController, UserPopController, UserWalletAccountController, VipController], + providers: [UserService, UserWalletAccountService, PointsService, VipService, StorageService], + exports: [UserService, VipService, StorageService, PointsService, UserWalletAccountService], +}) +export class UserModule { } diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.service.ts new file mode 100644 index 000000000..bfdd4f0a3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.service.ts @@ -0,0 +1,328 @@ +import { Injectable, Logger } from '@nestjs/common' +import { QueueService } from '@yikart/aitoearn-queue' +import { AppException, ResponseCode } from '@yikart/common' +import { MaterialGroupRepository, MediaGroupRepository, User, UserRepository, UserStatus } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import axios from 'axios' +import dayjs from 'dayjs' +import { google } from 'googleapis' +import { NewUser, UserCreateType } from './class/user.class' +import { UpdateUserInfoDto } from './dto/user.dto' +import { PointsService } from './points.service' +import { VipService } from './vip.service' + +@Injectable() +export class UserService { + logger = new Logger(UserService.name) + private oauth2Client: any + + constructor( + private readonly queueService: QueueService, + private readonly userRepository: UserRepository, + private readonly vipService: VipService, + private readonly pointsService: PointsService, + private readonly redisService: RedisService, + private readonly materialGroupRepository: MaterialGroupRepository, + private readonly mediaGroupRepository: MediaGroupRepository, + ) { + this.oauth2Client = new google.auth.OAuth2() + } + + /** + * 获取用户信息 + * @param mail + * @param all + * @returns + */ + async getUserInfoByMail(mail: string, all = false) { + const res = await this.userRepository.getUserInfoByMail(mail, all) + if (!res) + return null + const vipInfo = await this.vipService.getVipInfo(res) + res.vipInfo = vipInfo || undefined + return res + } + + /** + * 获取用户信息 + * @param id + * @returns + */ + async getUserInfoById(id: string) { + const res = await this.userRepository.getUserInfoById(id) + void this.redisService.setJson(`UserInfo:${id}`, res) + if (!res) + throw new AppException(1000, 'User does not exist') + + const vipInfo = await this.vipService.getVipInfo(res) + + res.vipInfo = vipInfo || undefined + return res + } + + /** + * 根据推广码获取用户信息 + * @param inviteCode + * @returns + */ + async getUserByPopularizeCode(inviteCode: string): Promise { + const res = await this.userRepository.getUserByPopularizeCode(inviteCode) + return res + } + + /** + * 邮箱创建用户 + * @param mail + * @param password + * @param salt + * @param inviteCode + * @returns + */ + async createUserByMail( + mail: string, + password: string, + salt: string, + inviteCode?: string, + ): Promise { + const newData = new NewUser(UserCreateType.mail, mail, { + password, + salt, + }) + newData.inviteCode = inviteCode + + const res = await this.userRepository.create( + newData, + ) + const userInfo = res.toJSON() + this.afterCreate(userInfo) + return userInfo + } + + /** + * 更新用户密码 + * @param id + * @param password + * @param salt + * @returns + */ + async updateUserPassword(id: string, password: string, salt: string): Promise { + const res = await this.userRepository.updateById( + id, + { + $set: { + password, + salt, + }, + }, + ) + + this.redisService.del(`UserInfo:${id}`) + return res !== null + } + + /** + * 更新用户信息 + * @param id + * @param data + * @returns + */ + async updateUserInfo( + id: string, + newdData: UpdateUserInfoDto, + ): Promise { + const res = await this.userRepository.updateUserInfo(id, newdData) + return res + } + + /** + * 更新用户信息 + * @param id + * @param data + * @returns + */ + async updateUserStatus( + id: string, + status: UserStatus, + ): Promise { + const res = await this.userRepository.updateUserStatus(id, status) + return res + } + + /** + * 更新用户信息 + * @param id + * @param data + * @returns + */ + async delete( + id: string, + ): Promise { + const res = await this.userRepository.deleteUser(id) + return res + } + + /** + * 生成用户推广码 + * @returns + */ + async generateUsePopularizeCode(id: string) { + const user = await this.getUserInfoById(id) + if (!user) + throw new AppException(ResponseCode.UserNotFound, 'User does not exist') + const res = await this.userRepository.generateUsePopularizeCode(user) + this.redisService.del(`UserInfo:${id}`) + return res + } + + /** + * 邮箱创建谷歌用户 + * @param clientId + * @param credential + * @returns + */ + async getUserInfoByGoogle( + clientId: string, + credential: string, + ): Promise { + this.logger.debug('Verifying Google token') + // 验证Google token + const ticket = await this.oauth2Client.verifyIdToken({ + idToken: credential, + audience: clientId, + }) + const googleUser = ticket.getPayload() + if (!googleUser) { + throw new Error('Invalid Google token') + } + + this.logger.debug('Google login success', { user: googleUser }) + + // 验证是否已经存在 + const userInfo = await this.userRepository.getUserInfoByMail(googleUser.email) + + if (userInfo && !userInfo.isDelete) { + return userInfo + } + + const googleAccount = { + googleId: googleUser.sub, + email: googleUser.email, + refreshToken: null, + } + + const newData = new NewUser(UserCreateType.google, googleUser.email, googleAccount) + + const res = await this.userRepository.create(newData) + const newUserInfo = res.toJSON() + this.afterCreate(newUserInfo) + return userInfo + } + + // 判断会员权益 + async checkUserVipRights(userId: string): Promise { + const userInfo = await this.getUserInfoById(userId) + if (userInfo.status !== UserStatus.OPEN) + throw new AppException(1000, 'The user has been banned and is unable to create tasks') + if ( + !userInfo.vipInfo + || dayjs(userInfo.vipInfo.expireTime).valueOf() < Date.now() + ) { + throw new AppException(1000, 'The user membership has expired. Please renew it') + } + return userInfo + } + + /** 获取我的积分记录 */ + async getMyPointsRecords(userId: string, page = 1, pageSize = 10) { + return await this.pointsService.getRecords(userId, page, pageSize) + } + + /** + * 登陆之后的逻辑处理 + * @param user + * @returns + */ + async afterLogin(user: User) { + // 上报用户数据 + await this.queueService.addTaskUserPortraitReportJob({ + userId: user.id, + lastLoginTime: (new Date()).toISOString(), + }) + return true + } + + /** + * 谷歌用户注销登录 + * @param clientId + * @param credential + * @param token + * @returns + */ + async cancelLoginByGoogle(clientId: string, credential: string, token: string) { + const params = new URLSearchParams({ + token, + }) + const response = await axios.post( + 'https://oauth2.googleapis.com/revoke', + params.toString(), + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }, + ) + + return response + } + + /** + * 用户创建后 + * @param user + * @returns + */ + private async afterCreate( + user: User, + ) { + // 创建默认的素材组/草稿箱组 + this.materialGroupRepository.createDefault(user.id) + this.mediaGroupRepository.createDefault(user.id) + + // 上报用户数据 + await this.queueService.addTaskUserPortraitReportJob({ + userId: user.id, + name: user.name, + avatar: user.avatar, + status: UserStatus.OPEN, + lastLoginTime: (new Date()).toISOString(), + }) + + // 生成推广码 + this.generateUsePopularizeCode(user.id) + // 用户创建积分 + this.pointsService.addPoints({ + userId: user.id, + amount: 10, + type: 'user_register', + description: '用户注册成功,获得10积分', + }) + + if (user.inviteCode) { + const inviteUser = await this.getUserByPopularizeCode(user.inviteCode) + if (inviteUser) { + this.pointsService.addPoints({ + userId: inviteUser.id, + amount: 20, + type: 'user_invite', + description: '用户邀请成功,获得20积分', + }) + + this.pointsService.addPoints({ + userId: user.id, + amount: 20, + type: 'user_invite', + description: '被用户邀请成功,获得20积分', + }) + } + } + // 派发新号任务 + await this.queueService.addTaskUserCreatePushJob({ userId: user.id }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.vo.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.vo.ts new file mode 100644 index 000000000..8d878bb3a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/user.vo.ts @@ -0,0 +1,23 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +// 积分记录 VO +export const pointsRecordVoSchema = z.object({ + id: z.string().describe('记录ID'), + userId: z.string().describe('用户ID'), + amount: z.number().describe('积分变动数量'), + balance: z.number().describe('变动后余额'), + type: z.string().describe('积分变动类型, ai_service ai 生成, user_register 用户注册'), + description: z.string().optional().describe('积分变动描述'), + metadata: z.record(z.string(), z.any()).optional().describe('额外信息'), +}) + +export class PointsRecordVo extends createZodDto(pointsRecordVoSchema) {} + +// 积分记录列表 VO +export const pointsRecordsVoSchema = z.object({ + list: z.array(pointsRecordVoSchema).describe('积分记录列表'), + total: z.number().describe('总记录数'), +}) + +export class PointsRecordsVo extends createZodDto(pointsRecordsVoSchema) {} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userPop.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userPop.controller.ts new file mode 100644 index 000000000..06d00b7bb --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userPop.controller.ts @@ -0,0 +1,25 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-05-06 15:50:54 + * @LastEditors: nevin + * @Description: 用户推广路由 + */ +import { Controller, Get } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { UserService } from './user.service' + +@ApiTags('用户推广') +@Controller('user/pop') +export class UserPopController { + constructor(private readonly userService: UserService) {} + + @ApiOperation({ + summary: '生成并获取自己的推广码', + }) + @Get('code') + async generateUsePopularizeCode(@GetToken() token: TokenInfo) { + return this.userService.generateUsePopularizeCode(token.id) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.controller.ts new file mode 100644 index 000000000..e49a2bd06 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.controller.ts @@ -0,0 +1,76 @@ +import { Body, Controller, Delete, Get, Logger, Param, Post, Put } from '@nestjs/common' +import { ApiOperation, ApiTags } from '@nestjs/swagger' +import { GetToken, TokenInfo } from '@yikart/aitoearn-auth' +import { TableDto } from '@yikart/common' +import { CreateUserWalletAccountDto, UpdateUserWalletAccountDto } from './dto/userWalletAccount.dto' +import { UserWalletAccountService } from './userWalletAccount.service' + +@ApiTags('用户钱包账户') +@Controller('userWalletAccount') +export class UserWalletAccountController { + private readonly logger = new Logger(UserWalletAccountController.name) + + constructor(private readonly userWalletAccountService: UserWalletAccountService) {} + + @ApiOperation({ + description: '创建钱包账户', + summary: '创建钱包账户', + }) + @Post() + async create( + @GetToken() tokenInfo: TokenInfo, + @Body() body: CreateUserWalletAccountDto, + ) { + return await this.userWalletAccountService.create(tokenInfo.id, body) + } + + @ApiOperation({ + description: '删除钱包账户', + summary: '删除钱包账户', + }) + @Delete(':id') + async delete( + @Param('id') id: string, + ) { + const res = await this.userWalletAccountService.delete(id) + return res + } + + @ApiOperation({ + description: '更新钱包账户', + summary: '更新钱包账户', + }) + @Put('') + async update( + @Body() body: UpdateUserWalletAccountDto, + ) { + const res = await this.userWalletAccountService.update(body) + return res + } + + @ApiOperation({ + description: '获取钱包账户信息', + summary: '获取钱包账户信息', + }) + @Get('info/:id') + async info( + @Param('id') id: string, + ) { + const res = await this.userWalletAccountService.info(id) + return res + } + + @ApiOperation({ + description: '获取钱包账户列表', + summary: '获取钱包账户列表', + }) + @Get('list/:pageNo/:pageSize') + list( + @GetToken() tokenInfo: TokenInfo, + @Param() params: TableDto, + ) { + return this.userWalletAccountService.list(params, { + userId: tokenInfo.id, + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.service.ts new file mode 100644 index 000000000..e6730112a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/userWalletAccount.service.ts @@ -0,0 +1,35 @@ +import { Injectable, Logger } from '@nestjs/common' +import { TableDto } from '@yikart/common' +import { UserWalletAccountRepository } from '@yikart/mongodb' +import { CreateUserWalletAccountDto, UpdateUserWalletAccountDto } from './dto/userWalletAccount.dto' + +@Injectable() +export class UserWalletAccountService { + private readonly logger = new Logger(UserWalletAccountService.name) + + constructor( + private readonly userWalletAccountRepository: UserWalletAccountRepository, + ) { } + + async create(userId: string, data: CreateUserWalletAccountDto) { + return this.userWalletAccountRepository.create({ userId, ...data }) + } + + async delete(id: string) { + return this.userWalletAccountRepository.delete(id) + } + + async update(data: UpdateUserWalletAccountDto) { + return this.userWalletAccountRepository.update(data.id, data) + } + + async info(id: string) { + return this.userWalletAccountRepository.info(id) + } + + async list(page: TableDto, query: { + userId?: string + }) { + return this.userWalletAccountRepository.list(page, query) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.controller.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.controller.ts new file mode 100644 index 000000000..56665f11a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.controller.ts @@ -0,0 +1,29 @@ +import { Controller } from '@nestjs/common' +import { Cron } from '@nestjs/schedule' +import { RedisService } from '@yikart/redis' +import { VipService } from './vip.service' + +const VipAddPointsLockKey = 'vip:add:points:lock:' + +@Controller() +export class VipController { + constructor( + private readonly vipService: VipService, + private readonly redisService: RedisService, + ) {} + + @Cron('0 30 0 * * *') + async dispatchVipIntegral() { + const theKeyHad = await this.redisService.get(VipAddPointsLockKey) + + if (theKeyHad) + return + + this.redisService.set(VipAddPointsLockKey, '1', 60 * 60 * 24) + const userList = await this.vipService.findAllNormelVipUsers() + + userList.forEach(async (user) => { + this.vipService.addVipPoints(user) + }) + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.service.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.service.ts new file mode 100644 index 000000000..0e9f43524 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/user/vip.service.ts @@ -0,0 +1,208 @@ +import { Injectable } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { User, UserRepository, UserVipInfo, VipRepository, VipStatus } from '@yikart/mongodb' +import { RedisService } from '@yikart/redis' +import dayjs from 'dayjs' +import { PointsService } from './points.service' + +const vipPointAddPointMap = new Map([ + [VipStatus.active_monthly, 700], + [VipStatus.active_nonrenewing, 700], + [VipStatus.active_yearly, 700], + [VipStatus.monthly_once, 700], + [VipStatus.yearly_once, 700], + [VipStatus.trialing, 100], +]) + +export const VipAddExpireTimeMap = new Map([ + [VipStatus.monthly_once, { + num: 1, + type: 'month', + }], + [VipStatus.yearly_once, { + num: 12, + type: 'month', + }], + [VipStatus.active_monthly, { + num: 1, + type: 'month', + }], + [VipStatus.active_yearly, { + num: 12, + type: 'month', + }], + [VipStatus.trialing, { + num: 7, + type: 'day', + }], + +]) +@Injectable() +export class VipService { + constructor( + private readonly userRepository: UserRepository, + private readonly vipRepository: VipRepository, + private readonly pointsService: PointsService, + private readonly redisService: RedisService, + ) { } + + /** + * 设置会员信息 + * @param userId + * @param vipStatus + */ + async setVipInfo( + userId: string, + vipStatus: VipStatus, + ): Promise { + const user = await this.userRepository.getById(userId) + if (!user) { + throw new AppException(ResponseCode.UserNotFound, 'User Not Found') + } + let vipInfo = await this.getVipInfo(user) + + // 限制体验会员只能有一次 + if (vipInfo && vipStatus === VipStatus.trialing) { + return false + } + + // 根据充值的时间类型决定过期时间 + const addInfo = VipAddExpireTimeMap.get(vipStatus) || { num: 0, type: 'day' } + const expireTime = dayjs().add(addInfo.num, addInfo.type) + + // 没有会员信息或者已经过期,设置会员信息 + if ( + !vipInfo || vipInfo.expireTime <= new Date() + ) { + vipInfo = { + expireTime: expireTime.toDate(), + status: vipStatus, + startTime: new Date(), + } + } + else { + vipInfo = { + expireTime: dayjs(vipInfo.expireTime).add(addInfo.num, addInfo.type).toDate(), + status: vipStatus, + startTime: vipInfo.startTime, + } + } + + const res = await this.vipRepository.updateInfo(user, vipInfo) + this.redisService.del(`UserInfo:${user.id}`) + + // 充值积分 + this.addNewVipPoints(user) + return res + } + + /** + * 增加新会员的积分 + * @param user + * @returns + */ + async addNewVipPoints(user: User): Promise<-1 | 0 | 1> { + if (!user.vipInfo || !user.vipInfo.status) { + return -1 + } + + if ([VipStatus.expired, VipStatus.none].includes(user.vipInfo.status)) { + return -1 + } + + const pointAmount = vipPointAddPointMap.get(user.vipInfo.status) + if (!pointAmount) + return -1 + + await this.pointsService.addPoints({ + userId: user.id, + amount: pointAmount, + type: 'vip_points', + description: 'VIP receive points every month', + }) + return 1 + } + + /** + * 增加会员积分 + * @param user + * @returns + */ + async addVipPoints(user: User): Promise<-1 | 0 | 1> { + if (!user.vipInfo) { + return -1 + } + + // 查找当前是否已经有发放记录 + const list = await this.pointsService.findVipOpintsAddRepordOfMonth( + user.id, + ) + if (list.length > 0) { + return 0 + } + + const pointAmount = vipPointAddPointMap.get(user.vipInfo.status) + if (!pointAmount) + return -1 + + await this.pointsService.addPoints({ + userId: user.id, + amount: pointAmount, + type: 'vip_points', + description: 'VIP receive points every month', + }) + return 1 + } + + async updateVipStatus(userId: string, status: VipStatus): Promise { + const res = await this.vipRepository.updateVipStatus(userId, status) + this.redisService.del(`UserInfo:${userId}`) + return res + } + + async clearVipInfo(userId: string): Promise { + const res = await this.vipRepository.clearVipInfo(userId) + return res + } + + private getNewStatus(vipInfo: UserVipInfo) { + const { expireTime, status } = vipInfo + if ( + expireTime <= new Date() + ) { + return VipStatus.expired + } + + if ( + !status + ) { + return VipStatus.none + } + + return status + } + + async getVipInfo(user: User): Promise { + const vipInfo = user.vipInfo + if (!vipInfo || !vipInfo.expireTime) { + return null + } + const newStatus = this.getNewStatus(vipInfo) + + if (vipInfo.status !== newStatus) { + this.vipRepository.updateVipStatus(user.id, newStatus) + this.redisService.del(`UserInfo:${user.id}`) + } + + vipInfo.status = newStatus + return vipInfo + } + + /** + * 查询当前有效的VIP会员列表 + * @returns + */ + async findAllNormelVipUsers(): Promise { + return this.vipRepository.findAllNormelVipUsers() + } +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/util/file.util.ts b/project/aitoearn-monorepo/apps/aitoearn-server/src/util/file.util.ts new file mode 100644 index 000000000..0634ceef3 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/util/file.util.ts @@ -0,0 +1,31 @@ +import { buildUrl, zodBuildUrl, zodTrimHost } from '@yikart/aws-s3' +import { config } from '../config' + +class FileUtile { + private hostUrl = config.awsS3.endpoint + public buildUrl(path = '') { + if (!path) + return path + return buildUrl(this.hostUrl, path) + } + + zodBuildUrl() { + return zodBuildUrl(this.hostUrl) + } + + /** + * 去除host部分,保留path部分 + * @param url + * @returns + */ + public trimHost(url: string) { + if (!url) + return url + return url.replace(this.hostUrl, '') + } + + zodTrimHost() { + return zodTrimHost(this.hostUrl) + } +} +export const fileUtile = new FileUtile() diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/back.ejs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/back.ejs new file mode 100644 index 000000000..1373d6719 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/back.ejs @@ -0,0 +1,297 @@ + + + + + + + + Authorization Result + + + + +
+ + +

Authorization Login

+ +
+
+ + Status: + + <% if (status===1 ) { %> + Authorization Successful + <% } else if (status==='error' ) { %> + Authorization Failed + <% } else { %> + Processing + <% } %> + +
+ + <% if (typeof message !=='undefined' && message) { %> +
+ Message: + + <%= message %> + +
+ <% } %> +
+ + <% if (status===1 ) { %> +
+ Authorization completed. You can now close this page or return to the application. +
+ <% } else if (status===0 ) { %> +
+ Authorization failed. Please check the information and try again. +
+ <% } else { %> +
+ + Processing authorization request, please wait... +
+ <% } %> + + <% if (typeof accountId !=='undefined' && accountId) { %> + + <% } %> + +
+ Page will automatically close in 5 seconds +
+
+ + + + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/meta.ejs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/meta.ejs new file mode 100644 index 000000000..48f8e7509 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/meta.ejs @@ -0,0 +1,15 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/regist.ejs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/regist.ejs new file mode 100644 index 000000000..c4d852399 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/regist.ejs @@ -0,0 +1,192 @@ + + + + + + AiToEarn Registration result + + + +
+ + +

Sign up for AiToEarn!

+ +
+ Congratulations! You have successfully registered an account. Welcome to join us! +
+ + <% if (typeof mail !== 'undefined' && mail) { %> + + <% } %> + + Return to the homepage + +
+

Warm Reminder:

+
    +
  • Please keep your account information safe
  • +
  • It is recommended to complete your personal information immediately
  • +
  • If you have any questions, please contact customer service
  • +
+
+ +
+ The page will automatically redirect to the home page in 10 seconds +
+
+ + + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/repassword.ejs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/repassword.ejs new file mode 100644 index 000000000..abaeff080 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/auth/repassword.ejs @@ -0,0 +1,198 @@ + + + + + + + AiToEarn Repassword result + + + + +
+

Repassword for AiToEarn!

+ + <% if (typeof status !=='undefined' && status) { %> +
+ Congratulations! You have successfully Repassword! +
+ <% } else { %> +
+ Repassword failed! The verification link is invalid or expired. +
+ <% } %> + <% if (typeof mail !=='undefined' && mail) { %> + + <% } %> + + Return to the homepage + +
+

Warm Reminder:

+
    +
  • Please keep your account information safe
  • +
  • It is recommended to complete your personal information immediately
  • +
  • If you have any questions, please contact customer service
  • +
+
+ +
+ The page will automatically redirect to the home page in 10 + seconds +
+
+ + + + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/cancel.hbs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/cancel.hbs new file mode 100644 index 000000000..2e0c04987 --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/cancel.hbs @@ -0,0 +1,201 @@ + + + + + + + AiToEarn Account Cancellation + + + +
+ {{!-- --}} + +

AiToEarn: Account Cancellation Confirmation

+ +
+ You have requested to cancel your AiToEarn account. Your cancel verification code is: +
+ + {{ code }} + +
+ Important: This action is irreversible. Once your account is cancelled: +
    +
  • All your data will be permanently deleted
  • +
  • You will lose access to all services
  • +
  • Your subscription will be cancelled
  • +
+
+ +
+

Instructions:

+
    +
  1. Your account will be permanently deleted within 24 hours
  2. +
  3. You will receive a confirmation email once the process is complete
  4. +
+
+ + +
+ + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/regist.hbs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/regist.hbs new file mode 100644 index 000000000..6e23e44ea --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/regist.hbs @@ -0,0 +1,187 @@ + + + + + + + + AiToEarn Verification Code + + + + +
+ + +

AiToEarn: Your Verification Code

+ +
+ You are receiving this message because a verification code has been requested for your account. +
+ +
+
Verification Code
+
{{ code }}
+
+ +
+

Instructions:

+
    +
  1. Use this code to complete your verification process
  2. +
  3. Enter the code in the verification field on the website
  4. +
  5. This code will expire in 10 minutes
  6. +
  7. If you didn't request this code, please ignore this email
  8. +
+
+ +
+ ⚠️ Never share this code with anyone. AiToEarn will never ask for this code via phone or email. +
+ + +
+ + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/repassword.hbs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/repassword.hbs new file mode 100644 index 000000000..6f0770a2f --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/mail/repassword.hbs @@ -0,0 +1,211 @@ + + + + + + + + Password Reset Verification Code + + + + +
+ + +

Password Reset Verification

+ +
+ You have requested to reset your password for the AiToEarn platform. + Please use the verification code below to complete the process: +
+ +
+ Your Verification Code +
{{ code }}
+
This code will expire in 10 minutes
+
+ +
+

How to reset your password:

+
    +
  1. Copy the verification code above
  2. +
  3. Return to the password reset page on AiToEarn
  4. +
  5. Enter the code in the verification field
  6. +
  7. Create a new password for your account
  8. +
+
+ +
+ Important: If you did not request a password reset, + please ignore this email or contact our support team immediately. +
+ + +
+ + + \ No newline at end of file diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/src/views/test/authPage.ejs b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/test/authPage.ejs new file mode 100644 index 000000000..bde0da39a --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/src/views/test/authPage.ejs @@ -0,0 +1,13 @@ + + + + + + + + +

测试授权页面:<%= id %>

+ <%- url%> + 点击授权 + + diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.app.json b/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.app.json new file mode 100644 index 000000000..83bd118ef --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.app.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "commonjs", + "paths": { + }, + "types": ["node"], + "strict": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "importHelpers": true, + "outDir": "../../dist/out-tsc", + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.json b/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.json new file mode 100644 index 000000000..350bf177d --- /dev/null +++ b/project/aitoearn-monorepo/apps/aitoearn-server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + }, + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/README.md b/project/aitoearn-monorepo/apps/browser-automation-worker/README.md new file mode 100644 index 000000000..c9e4dbe98 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/README.md @@ -0,0 +1,170 @@ +# Browser Automation Worker + +一个轻量级的浏览器自动化工具,使用 Multilogin 管理浏览器配置文件,通过 Playwright 控制浏览器。 + +## 功能特性 + +- 🚀 使用 Multilogin 启动和管理浏览器配置文件 +- 🪟 支持同时打开多个浏览器窗口 +- 🎯 自动导航到指定 URL +- 🍪 为每个窗口独立设置 cookies +- 💾 为每个窗口独立设置 localStorage +- 📄 通过配置文件传递参数 +- 🛡️ 完整的错误处理和日志记录 + +## 安装依赖 + +```bash +pnpm install +``` + +## 构建 + +```bash +pnpm nx build browser-automation-worker +``` + +## 使用方法 + +### 1. 创建配置文件 + +#### 配置文件 + +创建一个 JSON 配置文件,支持同时打开多个浏览器窗口: + +```json +{ + "multilogin": { + "email": "your-email@example.com", + "password": "your-multilogin-password", + "token": "optional-access-token" + }, + "folderId": "your-folder-id", + "profileId": "your-profile-id", + "windows": [ + { + "windowName": "Google Search", + "url": "https://www.google.com", + "cookies": [ + { + "name": "search_preference", + "value": "advanced", + "domain": ".google.com", + "path": "/", + "secure": true, + "httpOnly": false, + "sameSite": "Lax" + } + ], + "localStorage": [ + { + "name": "theme", + "value": "dark" + } + ] + }, + { + "windowName": "GitHub", + "url": "https://github.com", + "cookies": [ + { + "name": "user_session", + "value": "your-session-token", + "domain": ".github.com", + "path": "/", + "secure": true, + "httpOnly": true, + "sameSite": "Lax" + } + ], + "localStorage": [ + { + "name": "preferred_color_mode", + "value": "dark" + } + ] + } + ] +} +``` + +] +} + +```` + +### 2. 运行工具 + +```bash +node dist/apps/browser-automation-worker/main.js --config example-multi-window-task.json +```` + +## 配置文件格式 + +### 配置参数 + +- `multilogin`: Multilogin 配置 + - `email`: Multilogin 账户邮箱 + - `password`: Multilogin 账户密码 + - `token` (可选): Multilogin 访问令牌,如果提供则优先使用,无需 email/password +- `folderId`: Multilogin 文件夹 ID,包含要使用的配置文件 +- `profileId`: Multilogin 浏览器配置文件 ID +- `windows`: 窗口配置数组,每个元素包含: + - `url`: 要访问的目标 URL + - `cookies` (可选): 要设置的 HTTP cookie 数组 + - `localStorage` (可选): 要设置的 localStorage 数据数组 + +### Cookie 数据格式 + +- `name`: Cookie 名称 +- `value`: Cookie 值 +- `domain` (可选): Cookie 域名,默认为目标 URL 的主机名 +- `path` (可选): Cookie 路径,默认为 '/' +- `expires` (可选): Cookie 过期时间戳 +- `httpOnly` (可选): 是否仅限 HTTP 访问,默认为 false +- `secure` (可选): 是否仅在 HTTPS 下传输,默认为 false +- `sameSite` (可选): SameSite 策略,默认为 'Lax' + +### LocalStorage 数据格式 + +- `name`: localStorage 键名 +- `value`: localStorage 值 + +## 命令行选项 + +- `-c, --config `: 配置文件路径 (必需) +- `-h, --help`: 显示帮助信息 +- `-V, --version`: 显示版本信息 + +## 示例 + +查看 `example-task.json` 文件了解完整的配置示例。 + +## 错误处理 + +工具包含完整的错误处理机制: + +- 配置文件验证 +- Multilogin 连接错误 +- 浏览器启动失败 +- 网络连接问题 + +所有错误都会输出详细的错误信息和堆栈跟踪。 + +## 注意事项 + +1. 确保 Multilogin 客户端正在运行 +2. 确保提供的配置文件 ID 存在且可访问 +3. 配置文件中的敏感信息(如密码)应妥善保管 +4. 建议在生产环境中使用 Ansible Vault 等工具管理敏感配置 + +## 与 Ansible 集成 + +此工具设计用于与 Ansible 集成,Ansible 可以: + +1. 动态生成配置文件 +2. 部署和执行脚本 +3. 管理敏感凭据 +4. 清理临时文件 + +详细的 Ansible 集成方案请参考项目文档。 diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/eslint.config.mjs b/project/aitoearn-monorepo/apps/browser-automation-worker/eslint.config.mjs new file mode 100644 index 000000000..a778e262e --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/eslint.config.mjs @@ -0,0 +1,9 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + rules: { + 'no-console': 'off', // Allow console statements in CLI application + }, + }, +) diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/example-task.json b/project/aitoearn-monorepo/apps/browser-automation-worker/example-task.json new file mode 100644 index 000000000..7e6b5db0d --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/example-task.json @@ -0,0 +1,23 @@ +{ + "multilogin": { + "email": "meta@aitoearning.com", + "password": "Yika888666!", + "token": "eyJhbGciOiJIUzUxMiJ9.eyJicGRzLmJ1Y2tldCI6Im1seC1icGRzLXByb2QtZXUtMSIsIm1hY2hpbmVJRCI6IiIsInByb2R1Y3RJRCI6IiIsIndvcmtzcGFjZVJvbGUiOiJvd25lciIsInZlcmlmaWVkIjp0cnVlLCJzaGFyZElEIjoiY2JlMTM4MDAtYmJhZi00YzhmLTgwYjMtMTk3Zjg5NjM5NGYyIiwidXNlcklEIjoiM2Y2YWYwMmUtYzFhMS00MWVmLTkxZDQtZWE2MDg4OTFkNmU1IiwiZW1haWwiOiJtZXRhQGFpdG9lYXJuaW5nLmNvbSIsImlzQXV0b21hdGlvbiI6dHJ1ZSwid29ya3NwYWNlSUQiOiJhYWI1ZTQ5ZS0wZWMyLTQyNTQtYjMzZi00NDg2OGRkMzczYTIiLCJqdGkiOiIwZWE4N2YwMi0zMjU3LTRlOGYtYjViNy1kMzNhNzdmZGQzOGEiLCJzdWIiOiJNTFgiLCJpc3MiOiIzZjZhZjAyZS1jMWExLTQxZWYtOTFkNC1lYTYwODg5MWQ2ZTUiLCJpYXQiOjE3NTYxMDQzODMsImV4cCI6MTc1NjE5MDc4M30.W6Krz9ZtPaW6hCWDw73drZ_ncj9c73A9S-OQ4CtUa-MOtUnxawa_oK24AZ38BfQPDyP0MrE8xExUuhT5b5ayNw" + }, + "folderId": "aab5e49e-0ec2-4254-b33f-44868dd373a2", + "profileId": "514e2ae7-8f4e-416a-8404-439f5bc61dba", + "windows": [ + { + "url": "https://aitoearn.ai", + "localStorage": [ + { + "name": "User", + "value": "{\"state\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtYWlsIjoibDE0OTEyNTU3ODFAZ21haWwuY29tIiwiaWQiOiI2ODlmNDBiMmVhZjY1ZjYwMDJkNDYzMmIiLCJuYW1lIjoi55So5oi3Xzh1bnM0d0xCIiwiaWF0IjoxNzU2MjAwNjEwLCJleHAiOjE3NTg3OTI2MTB9.YyqKpVdQFiFOY7dlLfXA_r3vQPpRdM2DxUiMXy5iksg\",\"userInfo\":{\"_id\":\"689f40b2eaf65f6002d4632b\",\"name\":\"用户_8uns4wLB\",\"mail\":\"l1491255781@gmail.com\",\"status\":1,\"googleAccount\":{\"googleId\":\"108483019791958614324\",\"email\":\"l1491255781@gmail.com\",\"refreshToken\":null},\"score\":10,\"updatedAt\":\"2025-08-15T14:14:10.873Z\",\"createdAt\":\"2025-08-15T14:14:10.832Z\",\"popularizeCode\":\"95KXR\",\"id\":\"689f40b2eaf65f6002d4632b\"},\"isAddAccountPorxy\":false,\"lang\":\"zh-CN\",\"lastUpdateTime\":0,\"_hasHydrated\":true},\"version\":0}" + } + ] + }, + { + "url": "https://ping0.cc/" + } + ] +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/package.json b/project/aitoearn-monorepo/apps/browser-automation-worker/package.json new file mode 100644 index 000000000..180f41680 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/package.json @@ -0,0 +1,13 @@ +{ + "name": "@yikart/browser-automation-worker", + "version": "0.0.1", + "private": true, + "dependencies": { + "@yikart/multilogin": "^0.0.3", + "axios": "*", + "commander": "^14.0.0", + "playwright": "^1.40.0" + }, + "devDependencies": { + } +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/project.json b/project/aitoearn-monorepo/apps/browser-automation-worker/project.json new file mode 100644 index 000000000..986553a91 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/project.json @@ -0,0 +1,70 @@ +{ + "name": "browser-automation-worker", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/browser-automation-worker/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/apps/browser-automation-worker", + "tsConfig": "apps/browser-automation-worker/tsconfig.app.json", + "packageJson": "apps/browser-automation-worker/package.json", + "main": "apps/browser-automation-worker/src/main.ts", + "assets": ["apps/browser-automation-worker/*.md"], + "generatePackageJson": true, + "clean": true + } + }, + "prune-lockfile": { + "dependsOn": ["build"], + "cache": true, + "executor": "@nx/js:prune-lockfile", + "outputs": [ + "{workspaceRoot}/dist/apps/browser-automation-worker/package.json", + "{workspaceRoot}/dist/apps/browser-automation-worker/pnpm-lock.yaml" + ], + "options": { + "buildTarget": "build" + } + }, + "copy-workspace-modules": { + "dependsOn": ["build"], + "cache": true, + "outputs": [ + "{workspaceRoot}/dist/apps/browser-automation-worker/workspace_modules" + ], + "executor": "@nx/js:copy-workspace-modules", + "options": { + "buildTarget": "build" + } + }, + "prune": { + "dependsOn": ["prune-lockfile", "copy-workspace-modules"], + "executor": "nx:noop" + }, + "serve": { + "continuous": true, + "executor": "@nx/js:node", + "defaultConfiguration": "development", + "dependsOn": ["build"], + "options": { + "buildTarget": "browser-automation-worker:build", + "runBuildTargetDependencies": false + }, + "configurations": { + "development": { + "buildTarget": "browser-automation-worker:build:development" + }, + "production": { + "buildTarget": "browser-automation-worker:build:production" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/src/interfaces.ts b/project/aitoearn-monorepo/apps/browser-automation-worker/src/interfaces.ts new file mode 100644 index 000000000..ab6cf7da9 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/src/interfaces.ts @@ -0,0 +1,34 @@ +export interface MultiloginConfig { + email: string + password: string + token?: string +} + +export interface Cookie { + name: string + value: string + domain?: string + path?: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' +} + +export interface LocalStorageItem { + name: string + value: string +} + +export interface WindowConfig { + url: string + cookies?: Cookie[] + localStorage?: LocalStorageItem[] +} + +export interface BrowserTaskConfig { + multilogin: MultiloginConfig + folderId: string + profileId: string + windows: WindowConfig[] +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/src/main.ts b/project/aitoearn-monorepo/apps/browser-automation-worker/src/main.ts new file mode 100644 index 000000000..8b2a65099 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/src/main.ts @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import { MultiloginClient, MultiloginError } from '@yikart/multilogin' +import { Command } from 'commander' +import { chromium } from 'playwright' +import { BrowserTaskConfig, Cookie } from './interfaces' + +const program = new Command() + +program + .name('browser-automation-worker') + .description('Browser automation worker for executing tasks via Multilogin and Playwright') + .version('1.0.0') + .requiredOption('-c, --config ', 'Path to the task configuration file') + .parse() + +const options = program.opts() as { config: string } + +async function main() { + console.log('🚀 Starting browser automation worker...') + + const configPath = options.config + console.log(`📄 Reading configuration from: ${configPath}`) + + const configContent = readFileSync(configPath, 'utf-8') + const config: BrowserTaskConfig = JSON.parse(configContent) + + if (!config.multilogin) { + throw new Error('Invalid configuration: missing multilogin section') + } + + if (!config.multilogin.token && (!config.multilogin.email || !config.multilogin.password)) { + throw new Error('Invalid configuration: missing multilogin credentials (either token or email/password required)') + } + + if (!config.folderId || !config.profileId) { + throw new Error('Invalid configuration: missing folderId or profileId') + } + + if (!config.windows || config.windows.length === 0) { + throw new Error('Invalid configuration: missing windows configuration') + } + + console.log(`🪟 Will open ${config.windows.length} browser window(s)`) + + console.log(`🔐 Connecting to Multilogin with ${config.multilogin.token ? 'token' : `email: ${config.multilogin.email}`}`) + + const multiloginClient = new MultiloginClient({ + email: config.multilogin.email, + password: config.multilogin.password, + token: config.multilogin.token, + timeout: 600000, + useAutomationTokenRefresh: false, + }) + + console.log(`🌐 Starting browser profile: ${config.profileId}`) + + let profileData + let retryCount = 0 + const maxRetries = 3 + const retryableErrorCodes = ['CORE_DOWNLOADING_STARTED', 'CORE_DOWNLOADING_ALREADY_STARTED', 'LOCK_PROFILE_ERROR'] + + while (retryCount <= maxRetries) { + try { + try { + await multiloginClient.unlockProfiles({ ids: [config.profileId] }) + } + catch {} + profileData = await multiloginClient.startBrowserProfile(config.folderId, config.profileId, { + automation_type: 'playwright', + headless_mode: false, + }) + break + } + catch (error) { + if (error instanceof MultiloginError && error.response && typeof error.response === 'object') { + const response = error.response as { status?: { error_code?: string } } + const errorCode = response.status?.error_code + + if (retryableErrorCodes.includes(errorCode as string) && retryCount < maxRetries) { + retryCount++ + console.log(`⚠️ Retryable error (${errorCode}), attempt ${retryCount}/${maxRetries}. Retrying in 30 seconds...`) + await new Promise(resolve => setTimeout(resolve, 30000)) + continue + } + } + + throw error + } + } + + if (!profileData || !profileData.data || !profileData.data.port) { + throw new Error('Failed to start browser profile or get port') + } + + const browserPort = profileData.data.port + console.log(`🔗 Browser profile started on port: ${browserPort}`) + + const browserURL = `http://127.0.0.1:${browserPort}` + console.log(`🔌 Connecting to browser at: ${browserURL}`) + const browser = await chromium.connectOverCDP(browserURL, { timeout: 10000 }) + const context = browser.contexts()[0] + + // 获取现有页面 + let pages = context.pages() + + // 如果没有足够的页面,创建新页面 + while (pages.length < config.windows.length) { + await context.newPage() + pages = context.pages() + } + + console.log(`🪟 Found ${pages.length} existing pages, processing ${config.windows.length} windows...`) + + // 同步处理所有窗口 + await Promise.all(config.windows.map(async (windowConfig, i) => { + const page = pages[i] + console.log(`\n🪟 Processing Window ${i + 1}...`) + + console.log(`📍 Navigating to: ${windowConfig.url}`) + await page.goto(windowConfig.url) + + // 设置cookies + if (windowConfig.cookies && windowConfig.cookies.length > 0) { + console.log(`🍪 Setting ${windowConfig.cookies.length} cookies for Window ${i + 1}`) + + await context.addCookies(windowConfig.cookies.map((cookie: Cookie) => ({ + name: cookie.name, + value: cookie.value, + domain: cookie.domain || new URL(windowConfig.url).hostname, + path: cookie.path || '/', + expires: cookie.expires, + httpOnly: cookie.httpOnly || false, + secure: cookie.secure || false, + sameSite: cookie.sameSite || 'Lax', + }))) + + console.log(`✅ Cookies set successfully for Window ${i + 1}`) + } + + // 设置localStorage + if (windowConfig.localStorage && windowConfig.localStorage.length > 0) { + console.log(`💾 Setting ${windowConfig.localStorage.length} items in localStorage for Window ${i + 1}`) + + for (const item of windowConfig.localStorage) { + await page.evaluate((item) => { + localStorage.setItem(item.name, item.value) + }, item) + } + + console.log(`✅ localStorage data set successfully for Window ${i + 1}`) + } + await page.reload() + + console.log(`✅ Window ${i + 1} setup completed`) + })) + + console.log(`\n🎉 All ${config.windows.length} window(s) have been processed!`) + console.log('💡 Browser windows will remain open for manual interaction.') +} + +main().then(() => { + process.exit(0) +}).catch((error) => { + console.error('❌ Error occurred:', (error as Error).message) + console.error((error as Error).stack) + process.exit(1) +}) diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/task.json b/project/aitoearn-monorepo/apps/browser-automation-worker/task.json new file mode 100644 index 000000000..af8baf040 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/task.json @@ -0,0 +1,29 @@ +{ + "multilogin": { + "_id": "68abc60ec175073e9c5c2f38", + "email": "meta@aitoearning.com", + "password": "Yika888666!", + "maxProfiles": 10, + "currentProfiles": 2, + "createdAt": "2025-08-25T02:10:22.337Z", + "updatedAt": "2025-08-25T06:46:28.707Z", + "token": "eyJhbGciOiJIUzUxMiJ9.eyJicGRzLmJ1Y2tldCI6Im1seC1icGRzLXByb2QtZXUtMSIsIm1hY2hpbmVJRCI6IiIsInByb2R1Y3RJRCI6IiIsIndvcmtzcGFjZVJvbGUiOiJvd25lciIsInZlcmlmaWVkIjp0cnVlLCJzaGFyZElEIjoiY2JlMTM4MDAtYmJhZi00YzhmLTgwYjMtMTk3Zjg5NjM5NGYyIiwidXNlcklEIjoiM2Y2YWYwMmUtYzFhMS00MWVmLTkxZDQtZWE2MDg4OTFkNmU1IiwiZW1haWwiOiJtZXRhQGFpdG9lYXJuaW5nLmNvbSIsImlzQXV0b21hdGlvbiI6dHJ1ZSwid29ya3NwYWNlSUQiOiJhYWI1ZTQ5ZS0wZWMyLTQyNTQtYjMzZi00NDg2OGRkMzczYTIiLCJqdGkiOiJhMmViMDY1Ny02NjU0LTRkNmItOWI2OC0yNDMzZDY3YjkzOWQiLCJzdWIiOiJNTFgiLCJpc3MiOiIzZjZhZjAyZS1jMWExLTQxZWYtOTFkNC1lYTYwODg5MWQ2ZTUiLCJpYXQiOjE3NTY4MTcyMzQsImV4cCI6MjA3ODIyNTIzNH0.EEaYIVThdwuiM5cdNu6EfEAck3DEogeXGDWqHdh2g0ERK6IrH-vMK4Zz40qTbDfOKA5UM0eBVE93geYnlsKMPg", + "id": "68abc60ec175073e9c5c2f38" + }, + "folderId": "aab5e49e-0ec2-4254-b33f-44868dd373a2", + "profileId": "8ab4c311-dfb1-452d-8363-3ebb6d8083b8", + "windows": [ + { + "url": "https://aitoearn.ai", + "localStorage": [ + { + "name": "User", + "value": "{\"state\":{\"token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtYWlsIjoibDE0OTEyNTU3ODFAZ21haWwuY29tIiwiaWQiOiI2ODlmNDBiMmVhZjY1ZjYwMDJkNDYzMmIiLCJuYW1lIjoi55So5oi3Xzh1bnM0d0xCIiwiaWF0IjoxNzU2MjAwNjEwLCJleHAiOjE3NTg3OTI2MTB9.YyqKpVdQFiFOY7dlLfXA_r3vQPpRdM2DxUiMXy5iksg\",\"userInfo\":{\"_id\":\"689f40b2eaf65f6002d4632b\",\"name\":\"用户_8uns4wLB\",\"mail\":\"l1491255781@gmail.com\",\"status\":1,\"googleAccount\":{\"googleId\":\"108483019791958614324\",\"email\":\"l1491255781@gmail.com\",\"refreshToken\":null},\"score\":10,\"updatedAt\":\"2025-08-15T14:14:10.873Z\",\"createdAt\":\"2025-08-15T14:14:10.832Z\",\"popularizeCode\":\"95KXR\",\"id\":\"689f40b2eaf65f6002d4632b\"},\"isAddAccountPorxy\":false,\"lang\":\"zh-CN\",\"lastUpdateTime\":0,\"_hasHydrated\":true},\"version\":0}" + } + ] + }, + { + "url": "https://ping0.cc/" + } + ] +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.app.json b/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.app.json new file mode 100644 index 000000000..cd17d2326 --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "commonjs", + "types": ["node"], + "strict": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "importHelpers": true, + "outDir": "../../dist/out-tsc", + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.json b/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.json new file mode 100644 index 000000000..350bf177d --- /dev/null +++ b/project/aitoearn-monorepo/apps/browser-automation-worker/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + }, + "references": [ + { + "path": "./tsconfig.app.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/assets/global-bundle.pem b/project/aitoearn-monorepo/assets/global-bundle.pem new file mode 100644 index 000000000..99351b7e9 --- /dev/null +++ b/project/aitoearn-monorepo/assets/global-bundle.pem @@ -0,0 +1,2736 @@ +-----BEGIN CERTIFICATE----- +MIID/jCCAuagAwIBAgIQdOCSuA9psBpQd8EI368/0DANBgkqhkiG9w0BAQsFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIHNhLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTE5MTgwNjI2WhgPMjA2MTA1MTkxOTA2MjZaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgc2EtZWFzdC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAN6ftL6w8v3dB2yW +LjCxSP1D7ZsOTeLZOSCz1Zv0Gkd0XLhil5MdHOHBvwH/DrXqFU2oGzCRuAy+aZis +DardJU6ChyIQIciXCO37f0K23edhtpXuruTLLwUwzeEPdcnLPCX+sWEn9Y5FPnVm +pCd6J8edH2IfSGoa9LdErkpuESXdidLym/w0tWG/O2By4TabkNSmpdrCL00cqI+c +prA8Bx1jX8/9sY0gpAovtuFaRN+Ivg3PAnWuhqiSYyQ5nC2qDparOWuDiOhpY56E +EgmTvjwqMMjNtExfYx6Rv2Ndu50TriiNKEZBzEtkekwXInTupmYTvc7U83P/959V +UiQ+WSMCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU4uYHdH0+ +bUeh81Eq2l5/RJbW+vswDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB +AQBhxcExJ+w74bvDknrPZDRgTeMLYgbVJjx2ExH7/Ac5FZZWcpUpFwWMIJJxtewI +AnhryzM3tQYYd4CG9O+Iu0+h/VVfW7e4O3joWVkxNMb820kQSEwvZfA78aItGwOY +WSaFNVRyloVicZRNJSyb1UL9EiJ9ldhxm4LTT0ax+4ontI7zTx6n6h8Sr6r/UOvX +d9T5aUUENWeo6M9jGupHNn3BobtL7BZm2oS8wX8IVYj4tl0q5T89zDi2x0MxbsIV +5ZjwqBQ5JWKv7ASGPb+z286RjPA9R2knF4lJVZrYuNV90rHvI/ECyt/JrDqeljGL +BLl1W/UsvZo6ldLIpoMbbrb5 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBDCCAuygAwIBAgIQUfVbqapkLYpUqcLajpTJWzANBgkqhkiG9w0BAQsFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIG1lLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjIwNTA2MjMyMDA5WhgPMjA2MjA1MDcwMDIwMDlaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJIeovu3 +ewI9FVitXMQzvkh34aQ6WyI4NO3YepfJaePiv3cnyFGYHN2S1cR3UQcLWgypP5va +j6bfroqwGbCbZZcb+6cyOB4ceKO9Ws1UkcaGHnNDcy5gXR7aCW2OGTUfinUuhd2d +5bOGgV7JsPbpw0bwJ156+MwfOK40OLCWVbzy8B1kITs4RUPNa/ZJnvIbiMu9rdj4 +8y7GSFJLnKCjlOFUkNI5LcaYvI1+ybuNgphT3nuu5ZirvTswGakGUT/Q0J3dxP0J +pDfg5Sj/2G4gXiaM0LppVOoU5yEwVewhQ250l0eQAqSrwPqAkdTg9ng360zqCFPE +JPPcgI1tdGUgneECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +/2AJVxWdZxc8eJgdpbwpW7b0f7IwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQBYm63jTu2qYKJ94gKnqc+oUgqmb1mTXmgmp/lXDbxonjszJDOXFbri +3CCO7xB2sg9bd5YWY8sGKHaWmENj3FZpCmoefbUx++8D7Mny95Cz8R32rNcwsPTl +ebpd9A/Oaw5ug6M0x/cNr0qzF8Wk9Dx+nFEimp8RYQdKvLDfNFZHjPa1itnTiD8M +TorAqj+VwnUGHOYBsT/0NY12tnwXdD+ATWfpEHdOXV+kTMqFFwDyhfgRVNpTc+os +ygr8SwhnSCpJPB/EYl2S7r+tgAbJOkuwUvGT4pTqrzDQEhwE7swgepnHC87zhf6l +qN6mVpSnQKQLm6Ob5TeCEFgcyElsF5bH +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAOxu0I1QuMAhIeszB3fJIlkwCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyB1cy13ZXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTI0MjIwNjU5WhgPMjEyMTA1MjQyMzA2NTlaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgdXMtd2VzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEz4bylRcGqqDWdP7gQIIoTHdBK6FNtKH1 +4SkEIXRXkYDmRvL9Bci1MuGrwuvrka5TDj4b7e+csY0llEzHpKfq6nJPFljoYYP9 +uqHFkv77nOpJJ633KOr8IxmeHW5RXgrZo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBQQikVz8wmjd9eDFRXzBIU8OseiGzAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIwf06Mcrpw1O0EBLBBrp84m37NYtOkE/0Z0O+C7D41wnXi +EQdn6PXUVgdD23Gj82SrAjEAklhKs+liO1PtN15yeZR1Io98nFve+lLptaLakZcH ++hfFuUtCqMbaI8CdvJlKnPqT +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRALyWMTyCebLZOGcZZQmkmfcwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI0MjAyODAzWhgPMjEyMTA1MjQyMTI4MDNa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTMgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +wGFiyDyCrGqgdn4fXG12cxKAAfVvhMea1mw5h9CVRoavkPqhzQpAitSOuMB9DeiP +wQyqcsiGl/cTEau4L+AUBG8b9v26RlY48exUYBXj8CieYntOT9iNw5WtdYJa3kF/ +JxgI+HDMzE9cmHDs5DOO3S0uwZVyra/xE1ymfSlpOeUIOTpHRJv97CBUEpaZMUW5 +Sr6GruuOwFVpO5FX3A/jQlcS+UN4GjSRgDUJuqg6RRQldEZGCVCCmodbByvI2fGm +reGpsPJD54KkmAX08nOR8e5hkGoHxq0m2DLD4SrOFmt65vG47qnuwplWJjtk9B3Z +9wDoopwZLBOtlkPIkUllWm1P8EuHC1IKOA+wSP6XdT7cy8S77wgyHzR0ynxv7q/l +vlZtH30wnNqFI0y9FeogD0TGMCHcnGqfBSicJXPy9T4fU6f0r1HwqKwPp2GArwe7 +dnqLTj2D7M9MyVtFjEs6gfGWXmu1y5uDrf+CszurE8Cycoma+OfjjuVQgWOCy7Nd +jJswPxAroTzVfpgoxXza4ShUY10woZu0/J+HmNmqK7lh4NS75q1tz75in8uTZDkV +be7GK+SEusTrRgcf3tlgPjSTWG3veNzFDF2Vn1GLJXmuZfhdlVQDBNXW4MNREExS +dG57kJjICpT+r8X+si+5j51gRzkSnMYs7VHulpxfcwECAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQU4JWOpDBmUBuWKvGPZelw87ezhL8wDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBRNLMql7itvXSEFQRAnyOjivHz +l5IlWVQjAbOUr6ogZcwvK6YpxNAFW5zQr8F+fdkiypLz1kk5irx9TIpff0BWC9hQ +/odMPO8Gxn8+COlSvc+dLsF2Dax3Hvz0zLeKMo+cYisJOzpdR/eKd0/AmFdkvQoM +AOK9n0yYvVJU2IrSgeJBiiCarpKSeAktEVQ4rvyacQGr+QAPkkjRwm+5LHZKK43W +nNnggRli9N/27qYtc5bgr3AaQEhEXMI4RxPRXCLsod0ehMGWyRRK728a+6PMMJAJ +WHOU0x7LCEMPP/bvpLj3BdvSGqNor4ZtyXEbwREry1uzsgODeRRns5acPwTM6ff+ +CmxO2NZ0OktIUSYRmf6H/ZFlZrIhV8uWaIwEJDz71qvj7buhQ+RFDZ9CNL64C0X6 +mf0zJGEpddjANHaaVky+F4gYMtEy2K2Lcm4JGTdyIzUoIe+atzCnRp0QeIcuWtF+ +s8AjDYCVFNypcMmqbRmNpITSnOoCHSRuVkY3gutVoYyMLbp8Jm9SJnCIlEWTA6Rm +wADOMGZJVn5/XRTRuetVOB3KlQDjs9OO01XN5NzGSZO2KT9ngAUfh9Eqhf1iRWSP +nZlRbQ2NRCuY/oJ5N59mLGxnNJSE7giEKEBRhTQ/XEPIUYAUPD5fca0arKRJwbol +l9Se1Hsq0ZU5f+OZKQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAK7vlRrGVEePJpW1VHMXdlIwDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBhZi1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MTkxOTI4NDNaGA8yMTIxMDUxOTIwMjg0M1owgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBhZi1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMZiHOQC6x4o +eC7vVOMCGiN5EuLqPYHdceFPm4h5k/ZejXTf7kryk6aoKZKsDIYihkaZwXVS7Y/y +7Ig1F1ABi2jD+CYprj7WxXbhpysmN+CKG7YC3uE4jSvfvUnpzionkQbjJsRJcrPO +cZJM4FVaVp3mlHHtvnM+K3T+ni4a38nAd8xrv1na4+B8ZzZwWZXarfg8lJoGskSn +ou+3rbGQ0r+XlUP03zWujHoNlVK85qUIQvDfTB7n3O4s1XNGvkfv3GNBhYRWJYlB +4p8T+PFN8wG+UOByp1gV7BD64RnpuZ8V3dRAlO6YVAmINyG5UGrPzkIbLtErUNHO +4iSp4UqYvztDqJWWHR/rA84ef+I9RVwwZ8FQbjKq96OTnPrsr63A5mXTC9dXKtbw +XNJPQY//FEdyM3K8sqM0IdCzxCA1MXZ8+QapWVjwyTjUwFvL69HYky9H8eAER59K +5I7u/CWWeCy2R1SYUBINc3xxLr0CGGukcWPEZW2aPo5ibW5kepU1P/pzdMTaTfao +F42jSFXbc7gplLcSqUgWwzBnn35HLTbiZOFBPKf6vRRu8aRX9atgHw/EjCebi2xP +xIYr5Ub8u0QVHIqcnF1/hVzO/Xz0chj3E6VF/yTXnsakm+W1aM2QkZbFGpga+LMy +mFCtdPrELjea2CfxgibaJX1Q4rdEpc8DAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFDSaycEyuspo/NOuzlzblui8KotFMA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEAbosemjeTRsL9o4v0KadBUNS3V7gdAH+X4vH2 +Ee1Jc91VOGLdd/s1L9UX6bhe37b9WjUD69ur657wDW0RzxMYgQdZ27SUl0tEgGGp +cCmVs1ky3zEN+Hwnhkz+OTmIg1ufq0W2hJgJiluAx2r1ib1GB+YI3Mo3rXSaBYUk +bgQuujYPctf0PA153RkeICE5GI3OaJ7u6j0caYEixBS3PDHt2MJWexITvXGwHWwc +CcrC05RIrTUNOJaetQw8smVKYOfRImEzLLPZ5kf/H3Cbj8BNAFNsa10wgvlPuGOW +XLXqzNXzrG4V3sjQU5YtisDMagwYaN3a6bBf1wFwFIHQoAPIgt8q5zaQ9WI+SBns +Il6rd4zfvjq/BPmt0uI7rVg/cgbaEg/JDL2neuM9CJAzmKxYxLQuHSX2i3Fy4Y1B +cnxnRQETCRZNPGd00ADyxPKVoYBC45/t+yVusArFt+2SVLEGiFBr23eG2CEZu+HS +nDEgIfQ4V3YOTUNa86wvbAss1gbbnT/v1XCnNGClEWCWNCSRjwV2ZmQ/IVTmNHPo +7axTTBBJbKJbKzFndCnuxnDXyytdYRgFU7Ly3sa27WS2KFyFEDebLFRHQEfoYqCu +IupSqBSbXsR3U10OTjc9z6EPo1nuV6bdz+gEDthmxKa1NI+Qb1kvyliXQHL2lfhr +5zT5+Bs= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRAOLV6zZcL4IV2xmEneN1GwswDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyB1cy13ZXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE5MDg1OFoYDzIxMjEwNTE5MjAwODU4WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIHVzLXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC7koAKGXXlLixN +fVjhuqvz0WxDeTQfhthPK60ekRpftkfE5QtnYGzeovaUAiS58MYVzqnnTACDwcJs +IGTFE6Wd7sB6r8eI/3CwI1pyJfxepubiQNVAQG0zJETOVkoYKe/5KnteKtnEER3X +tCBRdV/rfbxEDG9ZAsYfMl6zzhEWKF88G6xhs2+VZpDqwJNNALvQuzmTx8BNbl5W +RUWGq9CQ9GK9GPF570YPCuURW7kl35skofudE9bhURNz51pNoNtk2Z3aEeRx3ouT +ifFJlzh+xGJRHqBG7nt5NhX8xbg+vw4xHCeq1aAe6aVFJ3Uf9E2HzLB4SfIT9bRp +P7c9c0ySGt+3n+KLSHFf/iQ3E4nft75JdPjeSt0dnyChi1sEKDi0tnWGiXaIg+J+ +r1ZtcHiyYpCB7l29QYMAdD0TjfDwwPayLmq//c20cPmnSzw271VwqjUT0jYdrNAm +gV+JfW9t4ixtE3xF2jaUh/NzL3bAmN5v8+9k/aqPXlU1BgE3uPwMCjrfn7V0I7I1 +WLpHyd9jF3U/Ysci6H6i8YKgaPiOfySimQiDu1idmPld659qerutUSemQWmPD3bE +dcjZolmzS9U0Ujq/jDF1YayN3G3xvry1qWkTci0qMRMu2dZu30Herugh9vsdTYkf +00EqngPbqtIVLDrDjEQLqPcb8QvWFQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBQBqg8Za/L0YMHURGExHfvPyfLbOTAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBACAGPMa1QL7P/FIO7jEtMelJ0hQlQepKnGtbKz4r +Xq1bUX1jnLvnAieR9KZmeQVuKi3g3CDU6b0mDgygS+FL1KDDcGRCSPh238Ou8KcG +HIxtt3CMwMHMa9gmdcMlR5fJF9vhR0C56KM2zvyelUY51B/HJqHwGvWuexryXUKa +wq1/iK2/d9mNeOcjDvEIj0RCMI8dFQCJv3PRCTC36XS36Tzr6F47TcTw1c3mgKcs +xpcwt7ezrXMUunzHS4qWAA5OGdzhYlcv+P5GW7iAA7TDNrBF+3W4a/6s9v2nQAnX +UvXd9ul0ob71377UhZbJ6SOMY56+I9cJOOfF5QvaL83Sz29Ij1EKYw/s8TYdVqAq ++dCyQZBkMSnDFLVe3J1KH2SUSfm3O98jdPORQrUlORQVYCHPls19l2F6lCmU7ICK +hRt8EVSpXm4sAIA7zcnR2nU00UH8YmMQLnx5ok9YGhuh3Ehk6QlTQLJux6LYLskd +9YHOLGW/t6knVtV78DgPqDeEx/Wu/5A8R0q7HunpWxr8LCPBK6hksZnOoUhhb8IP +vl46Ve5Tv/FlkyYr1RTVjETmg7lb16a8J0At14iLtpZWmwmuv4agss/1iBVMXfFk ++ZGtx5vytWU5XJmsfKA51KLsMQnhrLxb3X3zC+JRCyJoyc8++F3YEcRi2pkRYE3q +Hing +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAI+asxQA/MB1cGyyrC0MPpkwDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBjYS13ZXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIzMDkxMzIwMjEzNFoYDzIwNjMwOTEzMjEyMTMzWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGNhLXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMHvQITTZcfl2O +yfzRIAPKwzzlc8eXWdXef7VUsbezg3lm9RC+vArO4JuAzta/aLw1D94wPSRm9JXX +NkP3obO6Ql80/0doooU6BAPceD0xmEWC4aCFT/5KWsD6Sy2/Rjwq3NKBTwzxLwYK +GqVsBp8AdrzDTmdRETC+Dg2czEo32mTDAA1uMgqrz6xxeTYroj8NTSTp6jfE6C0n +YgzYmVQCEIjHqI49j7k3jfT3P2skCVKGJwQzoZnerFacKzXsDB18uIqU7NaMc2cX +kOd0gRqpyKOzAHU2m5/S4jw4UHdkoI3E7nkayuen8ZPKH2YqWtTXUrXGhSTT34nX +yiFgu+vTAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFHzz1NTd +TOm9zAv4d8l6XCFKSdJfMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAodBvd0cvXQYhFBef2evnuI9XA+AC/Q9P1nYtbp5MPA4aFhy5v9rjW8wwJX14 +l+ltd2o3tz8PFDBZ1NX2ooiWVlZthQxKn1/xDVKsTXHbYUXItPQ3jI5IscB5IML8 +oCzAbkoLXsSPNOVFP5P4l4cZEMqHGRnBag7hLJZvmvzZSBnz+ioC2jpjVluF8kDX +fQGNjqPECik68CqbSV0SaQ0cgEoYTDjwON5ZLBeS8sxR2abE/gsj4VFYl5w/uEBd +w3Tt9uGfIy+wd2tNj6isGC6PcbPMjA31jd+ifs2yNzigqkcYTTWFtnvh4a8xiecm +GHu2EgH0Jqzz500N7L3uQdPkdg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRALnItUH64VieFPvDUCOG5E0wDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNSBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjQwNTE1MjE1MDQxWhgPMjEyNDA1MTUyMjUwNDFa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTUgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +u2Bc0RjN0vB7EM+h0yts1jSqzDd1v5FcxCDbC7vKPVCq/1pYNTIxQj77HiMcYtvL +Bfi9AQFibU1C9gN62kUmSe0QaNGqQL2g/6YpB4qI8psIsCt3aIigbhwEEpebhIU/ +vhr/pvLKhkQOSLxJVlX0j18hU5RVqOefCdFm9FmjFLge/m1Yzv2aFifRKIzdtkfp +4VZBzh7EzP6lxkU3SAcW9yRu/t4oY274ICnGisv2TR15hHlP0wUP6p5S3ot2q/xJ +57x8nzI3kQyC6a+n+kSzZzITboKWrsx3Jd2PdB4VC84P/YoAC3cwfmacmQVT01c8 +io1eO+BxCtWUNbwCv0Hd10bHI18rVzJhJPb3xg1i1Sc3sbcrADOONsuhxqwffjUe +XdVMdsjX2mYxQ520qnh5DwQkx3JyW6QwI/ueU9xbMuPTwAauXil7B9qx1IDViYUw +BvMDnxYbYHlDezYIc4WoNoA2KflMnNtN2WDiM7tvQKmWI8yYZrNdnqBD0HYR+neP +z69Tqy8i24CDoR9o3s5LxR58SgFPqu9RWu8uL6vfNLL2M8qQ4VueOWSyzRs9b3W5 +GVjA4U1CxlF3EirHEjciq6UEXr5+ZVf79iXGOwBVDzuim1LYfoTBgkMXKxyEzWYT +QCzf6VPW4x7eMQIriLl18YocHrqJQ7+BfMziYjOh0rcCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUQdyu9F6eLFuxe437iU/GXyFHU1owDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCbbNUJRQ1gy1lKxCoszcyujCI5 +df0EGdadQL6BgaXws/uCFvHepB5lO62InAMTURCREeRtrCNyn1rEKnsqhAW0UokQ +Z+YOHcclgPsXmSQVjIUgnlE45mrPS/9mO8TzhCI3wyiELp6oa67RSiJ1Qcsypa4z +zHDkYdhFW3sxY8i2p2tqdkJz1ZEQd7FIpX+vrBVIkoqtGAn4urLaMq4CTNJCNepR +s4OGaoQVY43q2kcguRPDZVOFK5+GlrC2AzHMSVt5fFSCchgYxBZsS3UIVKm8YJ7v +1h85RwtNCHwwDt1uP43yLp5qfUmsfeaNmZiOk9AawxPCmy6XaSkQcLz+CQhG9T4W +siQMg6tagIUw1e4zFm7GXmeOCPc//ycGNDXgprMQzjK+AT4ed8iK+JnWlheMq5uf +XxQDSfakuAIEgJWPAzebjCo33O2j1PQfzbt1Ahs7f+gFczizfpatYkXcOTmLfG1l +QKj9jVNOIQSJt5PxH+QTDWQtkX/tGp/HS5a3dWusW/TnC3yakGqqfGx3cB/E00gF +geg0LYo1uOBjIYQbkp3Z6NKfcc/nb0ksV7feKm5f3rSO8NnA0Ou8YHb84LYDLYDf +VSR9SwSBhGw31otMTAsJdNTHJwfCcxfGtIvUfAsWUAh6qSo4es/hUV60pj01VWpq +Er+ItMFHuoSTmx18bw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRANxgyBbnxgTEOpDul2ZnC0UwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNjEwMTgxOTA3WhgPMjA2MTA2MTAxOTE5MDda +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +xnwSDAChrMkfk5TA4Dk8hKzStDlSlONzmd3fTG0Wqr5+x3EmFT6Ksiu/WIwEl9J2 +K98UI7vYyuZfCxUKb1iMPeBdVGqk0zb92GpURd+Iz/+K1ps9ZLeGBkzR8mBmAi1S +OfpwKiTBzIv6E8twhEn4IUpHsdcuX/2Y78uESpJyM8O5CpkG0JaV9FNEbDkJeBUQ +Ao2qqNcH4R0Qcr5pyeqA9Zto1RswgL06BQMI9dTpfwSP5VvkvcNUaLl7Zv5WzLQE +JzORWePvdPzzvWEkY/3FPjxBypuYwssKaERW0fkPDmPtykktP9W/oJolKUFI6pXp +y+Y6p6/AVdnQD2zZjW5FhQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBT+jEKs96LC+/X4BZkUYUkzPfXdqTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAIGQqgqcQ6XSGkmNebzR6DhadTbfDmbYeN5N0Vuzv+Tdmufb +tMGjdjnYMg4B+IVnTKQb+Ox3pL9gbX6KglGK8HupobmIRtwKVth+gYYz3m0SL/Nk +haWPYzOm0x3tJm8jSdufJcEob4/ATce9JwseLl76pSWdl5A4lLjnhPPKudUDfH+1 +BLNUi3lxpp6GkC8aWUPtupnhZuXddolTLOuA3GwTZySI44NfaFRm+o83N1jp+EwD +6e94M4cTRzjUv6J3MZmSbdtQP/Tk1uz2K4bQZGP0PZC3bVpqiesdE/xr+wbu8uHr +cM1JXH0AmXf1yIkTgyWzmvt0k1/vgcw5ixAqvvE= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEATCCAumgAwIBAgIRAMhw98EQU18mIji+unM2YH8wDQYJKoZIhvcNAQELBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMjA2MDYyMTQyMjJaGA8yMDYyMDYwNjIyNDIyMlowgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIeeRoLfTm+7 +vqm7ZlFSx+1/CGYHyYrOOryM4/Z3dqYVHFMgWTR7V3ziO8RZ6yUanrRcWVX3PZbF +AfX0KFE8OgLsXEZIX8odSrq86+/Th5eZOchB2fDBsUB7GuN2rvFBbM8lTI9ivVOU +lbuTnYyb55nOXN7TpmH2bK+z5c1y9RVC5iQsNAl6IJNvSN8VCqXh31eK5MlKB4DT ++Y3OivCrSGsjM+UR59uZmwuFB1h+icE+U0p9Ct3Mjq3MzSX5tQb6ElTNGlfmyGpW +Kh7GQ5XU1KaKNZXoJ37H53woNSlq56bpVrKI4uv7ATpdpFubOnSLtpsKlpLdR3sy +Ws245200pC8CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUp0ki +6+eWvsnBjQhMxwMW5pwn7DgwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUA +A4IBAQB2V8lv0aqbYQpj/bmVv/83QfE4vOxKCJAHv7DQ35cJsTyBdF+8pBczzi3t +3VNL5IUgW6WkyuUOWnE0eqAFOUVj0yTS1jSAtfl3vOOzGJZmWBbqm9BKEdu1D8O6 +sB8bnomwiab2tNDHPmUslpdDqdabbkWwNWzLJ97oGFZ7KNODMEPXWKWNxg33iHfS +/nlmnrTVI3XgaNK9qLZiUrxu9Yz5gxi/1K+sG9/Dajd32ZxjRwDipOLiZbiXQrsd +qzIMY4GcWf3g1gHL5mCTfk7dG22h/rhPyGV0svaDnsb+hOt6sv1McMN6Y3Ou0mtM +/UaAXojREmJmTSCNvs2aBny3/2sy +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBDCCA+ygAwIBAgIQID5K+9PRwKbkBpe2il3rBjANBgkqhkiG9w0BAQwFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIG14LWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjQwOTMwMTYxMzU1WhgPMjEyNDA5MzAxNzEzNTVaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgbXgtY2VudHJhbC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAI25WbHf +qmz3amzhuAXMOIrt/UZpWk24hjRYVmuY7ePaz01cDJ7K/hrEzhXDBBitF+KKxPXD +XXJ/KCP/joQL4ZaPiYPdeNQcO09wK31HMWsaJeHVHIuoWmKneK/ojpMtirjrdqo+ +3Hy7NMMhnkIy8KUzXTQX8hVFnDVfbjEhWppBFOtGlS8A8I85LALrj3Mvg8BPBl0Z +JZvPhTzQAu5u5wHaJVgVKflLl1wrpNvVyHx5ryar2bSPwLtu8HEFp1TbgiZ5VD/7 +AUCfa2+fh5BRRVl3fF+7DqKtPXR5a9bJsyiieyhQa43ZDPpLQgurSjO4tBH6Mfhc +x6cJ0OLOWK2ZhuGZGF4pGwH2srGrVzzecFyd2YTV8yuCTpo/lIIW74T+aV/udJXr +AwjjOoToYifg3U2ipNCcy+lEit/LE0BVmNNGhggIioitfgOIqhJD12/9f+jewcnI +pN510DngrY2+jRHYtqbS3rVpG6K5zsE1NPYDoJM6AA1iub/sVZzNU/TIt0TtuvY7 +izkg+o6AtUzlgqGaMM2emH+nkMyYWqteWHLeLEYRQ79laaQRKpuZV+cP5sCHSAYr +Fwww1SqtR5ad+OexNp0Hqmrl1LMucmWXSjbmEYfPYCkPV/hEWI/am/FX7VlkSNGy +S7+k0eCBawj/fQj2xlADIQnfa/qf+17e7bypAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFNtXwWHyvClTO1X0qH9DGxcwsgQtMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAjID7uJb3fQzOgjUA24WoBMd784H64geM +IoYIzm0e7f4ZkI/NZoKAA1UPF+kW49A7Y7Un6YHf1WH9Y8EH3zwaDmak3Lzbw9Q6 ++FY9BkynZ3LbGYdo1tdK/7dvRw/+gjMzN70vPmHQINvAt5r+33jwXicsvQtqLTP3 +vYLfR028B94eH73xOUKMoqp0cDNlHoAY413CCYlXUxdPnfQzhaF8EySwa2Ws9HOh +/oDeU4ztaPfnMIH0jx6tf584HzSgW7h0wXLOodtNlI74+wr/42ltFQxRWZd34FaK +R0OOJRBESuWeZUiCYucVn6Vw9jhJ5qVPcGPlSWim15UanTPh0+Ysqb4o5NO8GfWH +wAmLO4MA4BJw1zJSi/7RblwuksdHpQ/sWK+OoGxwhp48KHDTpOJLJ0aJaXQDNF+B +X/9Vl0zo05IQz+0C0L6RgBFiGnJtYW30Um9aHjbpz15Ov0tpajaL3iB7VlSKi/DS +e3CsjQqkh0sUpnU+T31eJiDiHZ5z+Oq4VJtI+gK9dcVP4LjSvlMUcl+scQifXr+X +GlJVyCphLVRtfHUv411ezQGmJFIJRMyfo+SKYWyDAvSrPxs6F6qDr54ShRuWBhrJ +qLJw1abcDEJFlTFsNcWghF4H0iTMg46UAr+RMJBB0uC9ZqY207wT63px7vgDXSCs +Pz5G0lqE904= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAMnRxsKLYscJV8Qv5pWbL7swCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyBzYS1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTE5MTgxNjAxWhgPMjEyMTA1MTkxOTE2MDFaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgc2EtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEjFOCZgTNVKxLKhUxffiDEvTLFhrmIqdO +dKqVdgDoELEzIHWDdC+19aDPitbCYtBVHl65ITu/9pn6mMUl5hhUNtfZuc6A+Iw1 +sBe0v0qI3y9Q9HdQYrGgeHDh8M5P7E2ho0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBS5L7/8M0TzoBZk39Ps7BkfTB4yJTAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIwI43O0NtWKTgnVv9z0LO5UMZYgSve7GvGTwqktZYCMObE +rUI4QerXM9D6JwLy09mqAjEAypfkdLyVWtaElVDUyHFkihAS1I1oUxaaDrynLNQK +Ou/Ay+ns+J+GyvyDUjBpVVW1 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/jCCA+agAwIBAgIQR71Z8lTO5Sj+as2jB7IWXzANBgkqhkiG9w0BAQwFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIHVzLXdlc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTI0MjIwMzIwWhgPMjEyMTA1MjQyMzAzMjBaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgdXMtd2VzdC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAM977bHIs1WJijrS +XQMfUOhmlJjr2v0K0UjPl52sE1TJ76H8umo1yR4T7Whkd9IwBHNGKXCJtJmMr9zp +fB38eLTu+5ydUAXdFuZpRMKBWwPVe37AdJRKqn5beS8HQjd3JXAgGKUNNuE92iqF +qi2fIqFMpnJXWo0FIW6s2Dl2zkORd7tH0DygcRi7lgVxCsw1BJQhFJon3y+IV8/F +bnbUXSNSDUnDW2EhvWSD8L+t4eiXYsozhDAzhBvojpxhPH9OB7vqFYw5qxFx+G0t +lSLX5iWi1jzzc3XyGnB6WInZDVbvnvJ4BGZ+dTRpOCvsoMIn9bz4EQTvu243c7aU +HbS/kvnCASNt+zk7C6lbmaq0AGNztwNj85Opn2enFciWZVnnJ/4OeefUWQxD0EPp +SjEd9Cn2IHzkBZrHCg+lWZJQBKbUVS0lLIMSsLQQ6WvR38jY7D2nxM1A93xWxwpt +ZtQnYRCVXH6zt2OwDAFePInWwxUjR5t/wu3XxPgpSfrmTi3WYtr1wFypAJ811e/P +yBtswWUQ6BNJQvy+KnOEeGfOwmtdDFYR+GOCfvCihzrKJrxOtHIieehR5Iw3cbXG +sm4pDzfMUVvDDz6C2M6PRlJhhClbatHCjik9hxFYEsAlqtVVK9pxaz9i8hOqSFQq +kJSQsgWw+oM/B2CyjcSqkSQEu8RLAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFPmrdxpRRgu3IcaB5BTqlprcKdTsMA4GA1UdDwEB/wQEAwIBhjAN +BgkqhkiG9w0BAQwFAAOCAgEAVdlxWjPvVKky3kn8ZizeM4D+EsLw9dWLau2UD/ls +zwDCFoT6euagVeCknrn+YEl7g20CRYT9iaonGoMUPuMR/cdtPL1W/Rf40PSrGf9q +QuxavWiHLEXOQTCtCaVZMokkvjuuLNDXyZnstgECuiZECTwhexUF4oiuhyGk9o01 +QMaiz4HX4lgk0ozALUvEzaNd9gWEwD2qe+rq9cQMTVq3IArUkvTIftZUaVUMzr0O +ed1+zAsNa9nJhURJ/6anJPJjbQgb5qA1asFcp9UaMT1ku36U3gnR1T/BdgG2jX3X +Um0UcaGNVPrH1ukInWW743pxWQb7/2sumEEMVh+jWbB18SAyLI4WIh4lkurdifzS +IuTFp8TEx+MouISFhz/vJDWZ84tqoLVjkEcP6oDypq9lFoEzHDJv3V1CYcIgOusT +k1jm9P7BXdTG7TYzUaTb9USb6bkqkD9EwJAOSs7DI94aE6rsSws2yAHavjAMfuMZ +sDAZvkqS2Qg2Z2+CI6wUZn7mzkJXbZoqRjDvChDXEB1mIhzVXhiNW/CR5WKVDvlj +9v1sdGByh2pbxcLQtVaq/5coM4ANgphoNz3pOYUPWHS+JUrIivBZ+JobjXcxr3SN +9iDzcu5/FVVNbq7+KN/nvPMngT+gduEN5m+EBjm8GukJymFG0m6BENRA0QSDqZ7k +zDY= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRAK5EYG3iHserxMqgg+0EFjgwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI0MjAyMzE2WhgPMjA2MTA1MjQyMTIzMTZa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +s1L6TtB84LGraLHVC+rGPhLBW2P0oN/91Rq3AnYwqDOuTom7agANwEjvLq7dSRG/ +sIfZsSV/ABTgArZ5sCmLjHFZAo8Kd45yA9byx20RcYtAG8IZl+q1Cri+s0XefzyO +U6mlfXZkVe6lzjlfXBkrlE/+5ifVbJK4dqOS1t9cWIpgKqv5fbE6Qbq4LVT+5/WM +Vd2BOljuBMGMzdZubqFKFq4mzTuIYfnBm7SmHlZfTdfBYPP1ScNuhpjuzw4n3NCR +EdU6dQv04Q6th4r7eiOCwbWI9LkmVbvBe3ylhH63lApC7MiiPYLlB13xBubVHVhV +q1NHoNTi+zA3MN9HWicRxQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBSuxoqm0/wjNiZLvqv+JlQwsDvTPDAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAFfTK/j5kv90uIbM8VaFdVbr/6weKTwehafT0pAk1bfLVX+7 +uf8oHgYiyKTTl0DFQicXejghXTeyzwoEkWSR8c6XkhD5vYG3oESqmt/RGvvoxz11 +rHHy7yHYu7RIUc3VQG60c4qxXv/1mWySGwVwJrnuyNT9KZXPevu3jVaWOVHEILaK +HvzQ2YEcWBPmde/zEseO2QeeGF8FL45Q1d66wqIP4nNUd2pCjeTS5SpB0MMx7yi9 +ki1OH1pv8tOuIdimtZ7wkdB8+JSZoaJ81b8sRrydRwJyvB88rftuI3YB4WwGuONT +ZezUPsmaoK69B0RChB0ofDpAaviF9V3xOWvVZfo= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGDzCCA/egAwIBAgIRAI0sMNG2XhaBMRN3zD7ZyoEwDQYJKoZIhvcNAQEMBQAw +gZ8xCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE4MDYGA1UEAwwv +QW1hem9uIFJEUyBQcmV2aWV3IHVzLWVhc3QtMiBSb290IENBIFJTQTQwOTYgRzEx +EDAOBgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjA1NzUwWhgPMjEyMTA1MTgyMTU3 +NTBaMIGfMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNl +cywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExODA2BgNV +BAMML0FtYXpvbiBSRFMgUHJldmlldyB1cy1lYXN0LTIgUm9vdCBDQSBSU0E0MDk2 +IEcxMRAwDgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC +CgKCAgEAh/otSiCu4Uw3hu7OJm0PKgLsLRqBmUS6jihcrkxfN2SHmp2zuRflkweU +BhMkebzL+xnNvC8okzbgPWtUxSmDnIRhE8J7bvSKFlqs/tmEdiI/LMqe/YIKcdsI +20UYmvyLIjtDaJIh598SHHlF9P8DB5jD8snJfhxWY+9AZRN+YVTltgQAAgayxkWp +M1BbvxpOnz4CC00rE0eqkguXIUSuobb1vKqdKIenlYBNxm2AmtgvQfpsBIQ0SB+8 +8Zip8Ef5rtjSw5J3s2Rq0aYvZPfCVIsKYepIboVwXtD7E9J31UkB5onLBQlaHaA6 +XlH4srsMmrew5d2XejQGy/lGZ1nVWNsKO0x/Az2QzY5Kjd6AlXZ8kq6H68hscA5i +OMbNlXzeEQsZH0YkId3+UsEns35AAjZv4qfFoLOu8vDotWhgVNT5DfdbIWZW3ZL8 +qbmra3JnCHuaTwXMnc25QeKgVq7/rG00YB69tCIDwcf1P+tFJWxvaGtV0g2NthtB +a+Xo09eC0L53gfZZ3hZw1pa3SIF5dIZ6RFRUQ+lFOux3Q/I3u+rYstYw7Zxc4Zeo +Y8JiedpQXEAnbw2ECHix/L6mVWgiWCiDzBnNLLdbmXjJRnafNSndSfFtHCnY1SiP +aCrNpzwZIJejoV1zDlWAMO+gyS28EqzuIq3WJK/TFE7acHkdKIcCAwEAAaNCMEAw +DwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUrmV1YASnuudfmqAZP4sKGTvScaEw +DgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBGpEKeQoPvE85tN/25 +qHFkys9oHDl93DZ62EnOqAUKLd6v0JpCyEiop4nlrJe+4KrBYVBPyKOJDcIqE2Sp +3cvgJXLhY4i46VM3Qxe8yuYF1ElqBpg3jJVj/sCQnYz9dwoAMWIJFaDWOvmU2E7M +MRaKx+sPXFkIjiDA6Bv0m+VHef7aedSYIY7IDltEQHuXoqNacGrYo3I50R+fZs88 +/mB3e/V7967e99D6565yf9Lcjw4oQf2Hy7kl/6P9AuMz0LODnGITwh2TKk/Zo3RU +Vgq25RDrT4xJK6nFHyjUF6+4cOBxVpimmFw/VP1zaXT8DN5r4HyJ9p4YuSK8ha5N +2pJc/exvU8Nv2+vS/efcDZWyuEdZ7eh1IJWQZlOZKIAONfRDRTpeQHJ3zzv3QVYy +t78pYp/eWBHyVIfEE8p2lFKD4279WYe+Uvdb8c4Jm4TJwqkSJV8ifID7Ub80Lsir +lPAU3OCVTBeVRFPXT2zpC4PB4W6KBSuj6OOcEu2y/HgWcoi7Cnjvp0vFTUhDFdus +Wz3ucmJjfVsrkEO6avDKu4SwdbVHsk30TVAwPd6srIdi9U6MOeOQSOSE4EsrrS7l +SVmu2QIDUVFpm8QAHYplkyWIyGkupyl3ashH9mokQhixIU/Pzir0byePxHLHrwLu +1axqeKpI0F5SBUPsaVNYY2uNFg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIQCREfzzVyDTMcNME+gWnTCTANBgkqhkiG9w0BAQsFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yMTA1MjQyMDQyMzNaGA8yMDYxMDUyNDIxNDIzM1ow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDL +1MT6br3L/4Pq87DPXtcjlXN3cnbNk2YqRAZHJayStTz8VtsFcGPJOpk14geRVeVk +e9uKFHRbcyr/RM4owrJTj5X4qcEuATYZbo6ou/rW2kYzuWFZpFp7lqm0vasV4Z9F +fChlhwkNks0UbM3G+psCSMNSoF19ERunj7w2c4E62LwujkeYLvKGNepjnaH10TJL +2krpERd+ZQ4jIpObtRcMH++bTrvklc+ei8W9lqrVOJL+89v2piN3Ecdd389uphst +qQdb1BBVXbhUrtuGHgVf7zKqN1SkCoktoWxVuOprVWhSvr7akaWeq0UmlvbEsujU +vADqxGMcJFyCzxx3CkJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFFk8UJmlhoxFT3PP12PvhvazHjT4MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG +9w0BAQsFAAOCAQEAfFtr2lGoWVXmWAsIo2NYre7kzL8Xb9Tx7desKxCCz5HOOvIr +8JMB1YK6A7IOvQsLJQ/f1UnKRh3X3mJZjKIywfrMSh0FiDf+rjcEzXxw2dGtUem4 +A+WMvIA3jwxnJ90OQj5rQ8bg3iPtE6eojzo9vWQGw/Vu48Dtw1DJo9210Lq/6hze +hPhNkFh8fMXNT7Q1Wz/TJqJElyAQGNOXhyGpHKeb0jHMMhsy5UNoW5hLeMS5ffao +TBFWEJ1gVfxIU9QRxSh+62m46JIg+dwDlWv8Aww14KgepspRbMqDuaM2cinoejv6 +t3dyOyHHrsOyv3ffZUKtQhQbQr+sUcL89lARsg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/jCCAuagAwIBAgIQSDZkikDRk3Yz12jYvk5noDANBgkqhkiG9w0BAQsFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIGFwLWVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjUwMjAxMDAxMDIxWhgPMjA2NTAyMDEwMTEwMjFaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgYXAtZWFzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKF7hTKGtJjPxPd5 +3reviptmh3QhN9tmcff4AIzU53BowyCGPgLJvw0JRSm4rgfDZAQ2rqQPbnWP9UBW +dZED3axk2bLWL6fddURHR5ckdb/Lv0NuNG/vdrG7l2V1jBasZNqAEVtASovdEHPX +Razz3DTedOSSKUttdJZtL4XbVLUMkB3YaT8a1UagQnuKO1pRiy9lvRUdP6hlhFW9 +qGduF/ZsTpFfxHp8VhOpAnMhwd4qHik8GLXdB9ajeKko84MU3tQn52GS4zvXWwY9 +dxeC0QjDcK2dG8m/43e01yDAonaVJPI3R849aNtiBA4U+8au0CNva5n6WNI5LQIO +XZmp5D8CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUg6d1N4oJ +YOE1eAtYJexRSiFCpXEwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB +AQAOOBaSlMo+jhgz0ADaFuvFyaI+EVDB9GFT1TLpAmEK0gfNDI/RlY/n7hSKHJj0 +1ZeGfJ+9THD78QBuK7VyYlUkngYDdvEpg45ZEVKWGUtUmrhkikSsa5N8vioE3xg9 +MeSfgP98ykXVQH1kLSTUhLku8aLkwRdK96kSIKHRMUy41A9lK20X9Rc1dSsFJ/qe +TUFGCxwurKwR6geC+cw8aNjXHZlHZGNJPS+gRTizN1WYjEtJkjd5kQeNPgyppX26 +kRB9Vh54WJNIrs8PB9JsX9exu8f8m3hg5FFZONH5iBg0gdEMvbePP7JsjZD5BbjU +5C26Juxnw77pZbi0m7m3cmxL +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAIJLTMpzGNxqHZ4t+c1MlCIwDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBhcC1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNTIxMzAzM1oYDzIwNjEwNTI1MjIzMDMzWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGFwLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtdHut0ZhJ9Nn2 +MpVafFcwHdoEzx06okmmhjJsNy4l9QYVeh0UUoek0SufRNMRF4d5ibzpgZol0Y92 +/qKWNe0jNxhEj6sXyHsHPeYtNBPuDMzThfbvsLK8z7pBP7vVyGPGuppqW/6m4ZBB +lcc9fsf7xpZ689iSgoyjiT6J5wlVgmCx8hFYc/uvcRtfd8jAHvheug7QJ3zZmIye +V4htOW+fRVWnBjf40Q+7uTv790UAqs0Zboj4Yil+hER0ibG62y1g71XcCyvcVpto +2/XW7Y9NCgMNqQ7fGN3wR1gjtSYPd7DO32LTzYhutyvfbpAZjsAHnoObmoljcgXI +QjfBcCFpAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFJI3aWLg +CS5xqU5WYVaeT5s8lpO0MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAUwATpJOcGVOs3hZAgJwznWOoTzOVJKfrqBum7lvkVH1vBwxBl9CahaKj3ZOt +YYp2qJzhDUWludL164DL4ZjS6eRedLRviyy5cRy0581l1MxPWTThs27z+lCC14RL +PJZNVYYdl7Jy9Q5NsQ0RBINUKYlRY6OqGDySWyuMPgno2GPbE8aynMdKP+f6G/uE +YHOf08gFDqTsbyfa70ztgVEJaRooVf5JJq4UQtpDvVswW2reT96qi6tXPKHN5qp3 +3wI0I1Mp4ePmiBKku2dwYzPfrJK/pQlvu0Gu5lKOQ65QdotwLAAoaFqrf9za1yYs +INUkHLWIxDds+4OHNYcerGp5Dw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtzCCAj2gAwIBAgIQc48r2iRBCPPCj3oRSgZxujAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC03IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTI0MDkxMjE1NTQ0MFoYDzIxMjQwOTEyMTY1NDQwWjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC03IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEVSBo1+i/T9w0C7C1qdnl +DfUXpFa+x4QYZvwLtt6m9L96k5irB7Wlw5168uTBW/ssRbv067PBnQEdZfI3iLKK +xWSpDFZN11tRneyDXag/fj1MCzBQ25WG+BitQdbzzuYuo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTiqTOsp/NisMSxMzARIGIKFcol4TAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAJlFgjECsVieUHNKE2Cmbj1wujERebHM +YNqCdnO//DeQ6Rh4SJkNaWB9LRmrmsdvIwIwf2iyQNetoO2JWNt7gzdRjO+7dF2+ +/LdagQCVp4R2N1Q6xzttRqZEK0lyAd0t7wlX +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAIO6ldra1KZvNWJ0TA1ihXEwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIxMjE0NTA1WhgPMjEyMTA1MjEyMjQ1MDVa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +sDN52Si9pFSyZ1ruh3xAN0nVqEs960o2IK5CPu/ZfshFmzAwnx/MM8EHt/jMeZtj +SM58LADAsNDL01ELpFZATjgZQ6xNAyXRXE7RiTRUvNkK7O3o2qAGbLnJq/UqF7Sw +LRnB8V6hYOv+2EjVnohtGCn9SUFGZtYDjWXsLd4ML4Zpxv0a5LK7oEC7AHzbUR7R +jsjkrXqSv7GE7bvhSOhMkmgxgj1F3J0b0jdQdtyyj109aO0ATUmIvf+Bzadg5AI2 +A9UA+TUcGeebhpHu8AP1Hf56XIlzPpaQv3ZJ4vzoLaVNUC7XKzAl1dlvCl7Klg/C +84qmbD/tjZ6GHtzpLKgg7kQEV7mRoXq8X4wDX2AFPPQl2fv+Kbe+JODqm5ZjGegm +uskABBi8IFv1hYx9jEulZPxC6uD/09W2+niFm3pirnlWS83BwVDTUBzF+CooUIMT +jhWkIIZGDDgMJTzouBHfoSJtS1KpUZi99m2WyVs21MNKHeWAbs+zmI6TO5iiMC+T +uB8spaOiHFO1573Fmeer4sy3YA6qVoqVl6jjTQqOdy3frAMbCkwH22/crV8YA+08 +hLeHXrMK+6XUvU+EtHAM3VzcrLbuYJUI2XJbzTj5g0Eb8I8JWsHvWHR5K7Z7gceR +78AzxQmoGEfV6KABNWKsgoCQnfb1BidDJIe3BsI0A6UCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUABp0MlB14MSHgAcuNSOhs3MOlUcwDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCv4CIOBSQi/QR9NxdRgVAG/pAh +tFJhV7OWb/wqwsNKFDtg6tTxwaahdCfWpGWId15OUe7G9LoPiKiwM9C92n0ZeHRz +4ewbrQVo7Eu1JI1wf0rnZJISL72hVYKmlvaWaacHhWxvsbKLrB7vt6Cknxa+S993 +Kf8i2Psw8j5886gaxhiUtzMTBwoDWak8ZaK7m3Y6C6hXQk08+3pnIornVSFJ9dlS +PAqt5UPwWmrEfF+0uIDORlT+cvrAwgSp7nUF1q8iasledycZ/BxFgQqzNwnkBDwQ +Z/aM52ArGsTzfMhkZRz9HIEhz1/0mJw8gZtDVQroD8778h8zsx2SrIz7eWQ6uWsD +QEeSWXpcheiUtEfzkDImjr2DLbwbA23c9LoexUD10nwohhoiQQg77LmvBVxeu7WU +E63JqaYUlOLOzEmNJp85zekIgR8UTkO7Gc+5BD7P4noYscI7pPOL5rP7YLg15ZFi +ega+G53NTckRXz4metsd8XFWloDjZJJq4FfD60VuxgXzoMNT9wpFTNSH42PR2s9L +I1vcl3w8yNccs9se2utM2nLsItZ3J0m/+QSRiw9hbrTYTcM9sXki0DtH2kyIOwYf +lOrGJDiYOIrXSQK36H0gQ+8omlrUTvUj4msvkXuQjlfgx6sgp2duOAfnGxE7uHnc +UhnJzzoe6M+LfGHkVQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICuDCCAj2gAwIBAgIQSAG6j2WHtWUUuLGJTPb1nTAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLW5vcnRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMDE2MzgyNloYDzIxMjEwNTIwMTczODI2WjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLW5vcnRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE2eqwU4FOzW8RV1W381Bd +olhDOrqoMqzWli21oDUt7y8OnXM/lmAuOS6sr8Nt61BLVbONdbr+jgCYw75KabrK +ZGg3siqvMOgabIKkKuXO14wtrGyGDt7dnKXg5ERGYOZlo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBS1Acp2WYxOcblv5ikZ3ZIbRCCW+zAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAJL84J08PBprxmsAKPTotBuVI3MyW1r8 +xQ0i8lgCQUf8GcmYjQ0jI4oZyv+TuYJAcwIxAP9Xpzq0Docxb+4N1qVhpiOfWt1O +FnemFiy9m1l+wv6p3riQMPV7mBVpklmijkIv3Q== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRALZLcqCVIJ25maDPE3sbPCIwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIxMjEzOTM5WhgPMjA2MTA1MjEyMjM5Mzla +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +ypKc+6FfGx6Gl6fQ78WYS29QoKgQiur58oxR3zltWeg5fqh9Z85K5S3UbRSTqWWu +Xcfnkz0/FS07qHX+nWAGU27JiQb4YYqhjZNOAq8q0+ptFHJ6V7lyOqXBq5xOzO8f ++0DlbJSsy7GEtJp7d7QCM3M5KVY9dENVZUKeJwa8PC5StvwPx4jcLeZRJC2rAVDG +SW7NAInbATvr9ssSh03JqjXb+HDyywiqoQ7EVLtmtXWimX+0b3/2vhqcH5jgcKC9 +IGFydrjPbv4kwMrKnm6XlPZ9L0/3FMzanXPGd64LQVy51SI4d5Xymn0Mw2kMX8s6 +Nf05OsWcDzJ1n6/Q1qHSxQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBRmaIc8eNwGP7i6P7AJrNQuK6OpFzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAIBeHfGwz3S2zwIUIpqEEI5/sMySDeS+3nJR+woWAHeO0C8i +BJdDh+kzzkP0JkWpr/4NWz84/IdYo1lqASd1Kopz9aT1+iROXaWr43CtbzjXb7/X +Zv7eZZFC8/lS5SROq42pPWl4ekbR0w8XGQElmHYcWS41LBfKeHCUwv83ATF0XQ6I +4t+9YSqZHzj4vvedrvcRInzmwWJaal9s7Z6GuwTGmnMsN3LkhZ+/GD6oW3pU/Pyh +EtWqffjsLhfcdCs3gG8x9BbkcJPH5aPAVkPn4wc8wuXg6xxb9YGsQuY930GWTYRf +schbgjsuqznW4HHakq4WNhs1UdTSTKkRdZz7FUQ= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEDzCCAvegAwIBAgIRAM2zAbhyckaqRim63b+Tib8wDQYJKoZIhvcNAQELBQAw +gZ8xCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE4MDYGA1UEAwwv +QW1hem9uIFJEUyBQcmV2aWV3IHVzLWVhc3QtMiBSb290IENBIFJTQTIwNDggRzEx +EDAOBgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjA0OTQ1WhgPMjA2MTA1MTgyMTQ5 +NDVaMIGfMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNl +cywgSW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExODA2BgNV +BAMML0FtYXpvbiBSRFMgUHJldmlldyB1cy1lYXN0LTIgUm9vdCBDQSBSU0EyMDQ4 +IEcxMRAwDgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA1ybjQMH1MkbvfKsWJaCTXeCSN1SG5UYid+Twe+TjuSqaXWonyp4WRR5z +tlkqq+L2MWUeQQAX3S17ivo/t84mpZ3Rla0cx39SJtP3BiA2BwfUKRjhPwOjmk7j +3zrcJjV5k1vSeLNOfFFSlwyDiVyLAE61lO6onBx+cRjelu0egMGq6WyFVidTdCmT +Q9Zw3W6LTrnPvPmEyjHy2yCHzH3E50KSd/5k4MliV4QTujnxYexI2eR8F8YQC4m3 +DYjXt/MicbqA366SOoJA50JbgpuVv62+LSBu56FpzY12wubmDZsdn4lsfYKiWxUy +uc83a2fRXsJZ1d3whxrl20VFtLFHFQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBRC0ytKmDYbfz0Bz0Psd4lRQV3aNTAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQELBQADggEBAGv8qZu4uaeoF6zsbumauz6ea6tdcWt+hGFuwGrb +tRbI85ucAmVSX06x59DJClsb4MPhL1XmqO3RxVMIVVfRwRHWOsZQPnXm8OYQ2sny +rYuFln1COOz1U/KflZjgJmxbn8x4lYiTPZRLarG0V/OsCmnLkQLPtEl/spMu8Un7 +r3K8SkbWN80gg17Q8EV5mnFwycUx9xsTAaFItuG0en9bGsMgMmy+ZsDmTRbL+lcX +Fq8r4LT4QjrFz0shrzCwuuM4GmcYtBSxlacl+HxYEtAs5k10tmzRf6OYlY33tGf6 +1tkYvKryxDPF/EDgGp/LiBwx6ixYMBfISoYASt4V/ylAlHA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtTCCAjqgAwIBAgIRAK9BSZU6nIe6jqfODmuVctYwCgYIKoZIzj0EAwMwgZkx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h +em9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTIxMjIxMzA5WhgPMjEyMTA1MjEyMzEzMDlaMIGZMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv +biBSRFMgY2EtY2VudHJhbC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEUkEERcgxneT5H+P+fERcbGmf +bVx+M7rNWtgWUr6w+OBENebQA9ozTkeSg4c4M+qdYSObFqjxITdYxT1z/nHz1gyx +OKAhLjWu+nkbRefqy3RwXaWT680uUaAP6ccnkZOMo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBSN6fxlg0s5Wny08uRBYZcQ3TUoyzAOBgNVHQ8BAf8EBAMC +AYYwCgYIKoZIzj0EAwMDaQAwZgIxAORaz+MBVoFBTmZ93j2G2vYTwA6T5hWzBWrx +CrI54pKn5g6At56DBrkjrwZF5T1enAIxAJe/LZ9xpDkAdxDgGJFN8gZYLRWc0NRy +Rb4hihy5vj9L+w9uKc9VfEBIFuhT7Z3ljg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQB/57HSuaqUkLaasdjxUdPjANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE3NDAzNFoYDzIwNjEwNTE5MTg0MDM0WjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtbkaoVsUS76o +TgLFmcnaB8cswBk1M3Bf4IVRcwWT3a1HeJSnaJUqWHCJ+u3ip/zGVOYl0gN1MgBb +MuQRIJiB95zGVcIa6HZtx00VezDTr3jgGWRHmRjNVCCHGmxOZWvJjsIE1xavT/1j +QYV/ph4EZEIZ/qPq7e3rHohJaHDe23Z7QM9kbyqp2hANG2JtU/iUhCxqgqUHNozV +Zd0l5K6KnltZQoBhhekKgyiHqdTrH8fWajYl5seD71bs0Axowb+Oh0rwmrws3Db2 +Dh+oc2PwREnjHeca9/1C6J2vhY+V0LGaJmnnIuOANrslx2+bgMlyhf9j0Bv8AwSi +dSWsobOhNQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQb7vJT +VciLN72yJGhaRKLn6Krn2TAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAAxEj8N9GslReAQnNOBpGl8SLgCMTejQ6AW/bapQvzxrZrfVOZOYwp/5oV0f +9S1jcGysDM+DrmfUJNzWxq2Y586R94WtpH4UpJDGqZp+FuOVJL313te4609kopzO +lDdmd+8z61+0Au93wB1rMiEfnIMkOEyt7D2eTFJfJRKNmnPrd8RjimRDlFgcLWJA +3E8wca67Lz/G0eAeLhRHIXv429y8RRXDtKNNz0wA2RwURWIxyPjn1fHjA9SPDkeW +E1Bq7gZj+tBnrqz+ra3yjZ2blss6Ds3/uRY6NYqseFTZWmQWT7FolZEnT9vMUitW +I0VynUbShVpGf6946e0vgaaKw20= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/jCCAuagAwIBAgIQGyUVTaVjYJvWhroVEiHPpDANBgkqhkiG9w0BAQsFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIHVzLXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTE5MTkwNDA2WhgPMjA2MTA1MTkyMDA0MDZaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgdXMtd2VzdC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANhyXpJ0t4nigRDZ +EwNtFOem1rM1k8k5XmziHKDvDk831p7QsX9ZOxl/BT59Pu/P+6W6SvasIyKls1sW +FJIjFF+6xRQcpoE5L5evMgN/JXahpKGeQJPOX9UEXVW5B8yi+/dyUitFT7YK5LZA +MqWBN/LtHVPa8UmE88RCDLiKkqiv229tmwZtWT7nlMTTCqiAHMFcryZHx0pf9VPh +x/iPV8p2gBJnuPwcz7z1kRKNmJ8/cWaY+9w4q7AYlAMaq/rzEqDaN2XXevdpsYAK +TMMj2kji4x1oZO50+VPNfBl5ZgJc92qz1ocF95SAwMfOUsP8AIRZkf0CILJYlgzk +/6u6qZECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm5jfcS9o ++LwL517HpB6hG+PmpBswDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB +AQAcQ6lsqxi63MtpGk9XK8mCxGRLCad51+MF6gcNz6i6PAqhPOoKCoFqdj4cEQTF +F8dCfa3pvfJhxV6RIh+t5FCk/y6bWT8Ls/fYKVo6FhHj57bcemWsw/Z0XnROdVfK +Yqbc7zvjCPmwPHEqYBhjU34NcY4UF9yPmlLOL8uO1JKXa3CAR0htIoW4Pbmo6sA4 +6P0co/clW+3zzsQ92yUCjYmRNeSbdXbPfz3K/RtFfZ8jMtriRGuO7KNxp8MqrUho +HK8O0mlSUxGXBZMNicfo7qY8FD21GIPH9w5fp5oiAl7lqFzt3E3sCLD3IiVJmxbf +fUwpGd1XZBBSdIxysRLM6j48 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrTCCAjOgAwIBAgIQU+PAILXGkpoTcpF200VD/jAKBggqhkjOPQQDAzCBljEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6 +b24gUkRTIGFwLWVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTAgFw0yMTA1MjUyMTQ1MTFaGA8yMTIxMDUyNTIyNDUxMVowgZYxCzAJBgNV +BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD +VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE +UyBhcC1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAT3tFKE8Kw1sGQAvNLlLhd8OcGhlc7MiW/s +NXm3pOiCT4vZpawKvHBzD76Kcv+ZZzHRxQEmG1/muDzZGlKR32h8AAj+NNO2Wy3d +CKTtYMiVF6Z2zjtuSkZQdjuQbe4eQ7qjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFAiSQOp16Vv0Ohpvqcbd2j5RmhYNMA4GA1UdDwEB/wQEAwIBhjAKBggq +hkjOPQQDAwNoADBlAjBVsi+5Ape0kOhMt/WFkANkslD4qXA5uqhrfAtH29Xzz2NV +tR7akiA771OaIGB/6xsCMQCZt2egCtbX7J0WkuZ2KivTh66jecJr5DHvAP4X2xtS +F/5pS+AUhcKTEGjI9jDH3ew= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICuDCCAj2gAwIBAgIQT5mGlavQzFHsB7hV6Mmy6TAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNDIwNTAxNVoYDzIxMjEwNTI0MjE1MDE1WjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEcm4BBBjYK7clwm0HJRWS +flt3iYwoJbIXiXn9c1y3E+Vb7bmuyKhS4eO8mwO4GefUcXObRfoHY2TZLhMJLVBQ +7MN2xDc0RtZNj07BbGD3VAIFRTDX0mH9UNYd0JQM3t/Oo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRrd5ITedfAwrGo4FA9UaDaGFK3rjAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAPBNqmVv1IIA3EZyQ6XuVf4gj79/DMO8 +bkicNS1EcBpUqbSuU4Zwt2BYc8c/t7KVOQIxAOHoWkoKZPiKyCxfMtJpCZySUG+n +sXgB/LOyWE5BJcXUfm+T1ckeNoWeUUMOLmnJjg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRAJcDeinvdNrDQBeJ8+t38WQwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNCBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjIwNTI1MTY0OTE2WhgPMjA2MjA1MjUxNzQ5MTZa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTQgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +k8DBNkr9tMoIM0NHoFiO7cQfSX0cOMhEuk/CHt0fFx95IBytx7GHCnNzpM27O5z6 +x6iRhfNnx+B6CrGyCzOjxvPizneY+h+9zfvNz9jj7L1I2uYMuiNyOKR6FkHR46CT +1CiArfVLLPaTqgD/rQjS0GL2sLHS/0dmYipzynnZcs613XT0rAWdYDYgxDq7r/Yi +Xge5AkWQFkMUq3nOYDLCyGGfQqWKkwv6lZUHLCDKf+Y0Uvsrj8YGCI1O8mF0qPCQ +lmlfaDvbuBu1AV+aabmkvyFj3b8KRIlNLEtQ4N8KGYR2Jdb82S4YUGIOAt4wuuFt +1B7AUDLk3V/u+HTWiwfoLQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBSNpcjz6ArWBtAA+Gz6kyyZxrrgdDAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAGJEd7UgOzHYIcQRSF7nSYyjLROyalaIV9AX4WXW/Cqlul1c +MblP5etDZm7A/thliZIWAuyqv2bNicmS3xKvNy6/QYi1YgxZyy/qwJ3NdFl067W0 +t8nGo29B+EVK94IPjzFHWShuoktIgp+dmpijB7wkTIk8SmIoe9yuY4+hzgqk+bo4 +ms2SOXSN1DoQ75Xv+YmztbnZM8MuWhL1T7hA4AMorzTQLJ9Pof8SpSdMHeDsHp0R +01jogNFkwy25nw7cL62nufSuH2fPYGWXyNDg+y42wKsKWYXLRgUQuDVEJ2OmTFMB +T0Vf7VuNijfIA9hkN2d3K53m/9z5WjGPSdOjGhg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/jCCAuagAwIBAgIQRiwspKyrO0xoxDgSkqLZczANBgkqhkiG9w0BAQsFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIHVzLXdlc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTI0MjE1OTAwWhgPMjA2MTA1MjQyMjU5MDBaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgdXMtd2VzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL53Jk3GsKiu+4bx +jDfsevWbwPCNJ3H08Zp7GWhvI3Tgi39opfHYv2ku2BKFjK8N2L6RvNPSR8yplv5j +Y0tK0U+XVNl8o0ibhqRDhbTuh6KL8CFINWYzAajuxFS+CF0U6c1Q3tXLBdALxA7l +FlXJ71QrP06W31kRe7kvgrvO7qWU3/OzUf9qYw4LSiR1/VkvvRCTqcVNw09clw/M +Jbw6FSgweN65M9j7zPbjGAXSHkXyxH1Erin2fa+B9PE4ZDgX9cp2C1DHewYJQL/g +SepwwcudVNRN1ibKH7kpMrgPnaNIVNx5sXVsTjk6q2ZqYw3SVHegltJpLy/cZReP +mlivF2kCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUmTcQd6o1 +CuS65MjBrMwQ9JJjmBwwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB +AQAKSDSIzl956wVddPThf2VAzI8syw9ngSwsEHZvxVGHBvu5gg618rDyguVCYX9L +4Kw/xJrk6S3qxOS2ZDyBcOpsrBskgahDFIunzoRP3a18ARQVq55LVgfwSDQiunch +Bd05cnFGLoiLkR5rrkgYaP2ftn3gRBRaf0y0S3JXZ2XB3sMZxGxavYq9mfiEcwB0 +LMTMQ1NYzahIeG6Jm3LqRqR8HkzP/Ztq4dT2AtSLvFebbNMiWqeqT7OcYp94HTYT +zqrtaVdUg9bwyAUCDgy0GV9RHDIdNAOInU/4LEETovrtuBU7Z1q4tcHXvN6Hd1H8 +gMb0mCG5I393qW5hFsA/diFb +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAIgSQsm7XtddDiXpo3j09qYwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNyBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjQwOTEyMTU1NDM2WhgPMjEyNDA5MTIxNjU0MzZa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTcgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +jLfyuFmUeGd3WSDotJVQ+XjY20Yt75KWDFjXNLNZ7+/97RshOjZ0M3dZ+CcKmIQz +37vjb80kk0p+jeuaLxprWjc5kLJjaFZNPA8mxM/UmARvMvBRrO4uRRRQvYFyXqi+ +Frsl46t/+nyarL09ICx/1reZIzsOsI9BcDM0CK1hqQSwrIjOK2mXuHVfrufXjLnR +wZA2rOonQJJAXgOo1RvD11xmOUUIglP2ljAZZskxL+zU8d5k9Ed4HkGsOY3ywbP0 +/E+Fd33Gli3EF01wAbIaZL0vuW2rW4oxjh8QW7O7Sfnsr9fdNU2Tye1jSCxle5e3 +2Sfq+0Iw5TiT0KYTmcpcKzvljd1wwHdiNKeo7ZCVIer/lGl62PoAb2NWeLepk0IO +IwTZ+Dq76hs9XZJbYxWhXpVcT31b9e3++jROMPz7Mzi/wMg+RBWXgjDuuj1Z16sF +MFfK6QEmD8SOKL3GAKn7PBQxZea+j6iF7G1nGz1/Wfvzz7I8DocnAmQZabKzEnpR +LTEU2LZ86pkeLkM3uI9jm/VWeV4xLv/dC7trDkTUSHpJ4J5/ItMecdAlhCEJ2qM2 +x46OUbGYsf9gvacLkqQoS/XFL8R4bxR01URlHU+98yfODBCehulmnKOz3dQOth2c +sF9v2spYG2gmY3jg7N8suvPbzYKWmL7pVCQtqVOZPkkCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUy2Eg468T4F9w7gg9AxeZBj+gjKYwDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCGLMyGrhwhqoDRtJ6gGx1SkJd4 +uxG3R4VFKS23JhDJKoDISjcCrVQu5Z3xSj157HmDnc/TEHgW/t1y472QOagzN2RD +9xgFBGfU1sHxKX79WBSf93L3alM+oE1tS+drDlBPV4gzxvCc+zvUYQKWjz/W6q9f +bdrk16J2/yOyli3XpEqf1o4c9FVnAbi9c3zdugfARskwypo2LHDuo8z+u/Ab7j1y +NZ8HtLg/hwVjfSoPLD8guYSZHXYn6ed7AmOUUJjlauWUfCCvU/F+Dk51JMLlVVK+ +y9ZgHXa8YDsGsCmpdgfC3MLSEVkx87mxj1rkFLI03q9i6L2BOWQtgd6NmvYd7pJx +cqx6bmSh43CscGDEngIB50XWqXd4QlVKmPPgDnd55szfnNQzzG9q8I9KTEv+B/Ck +apl2XxVAEUdb26Wi7RH+9Wms6HQeU4i7cDuWpm+EfZZmdjwvA4bs3Ox07SkenHqu +Ti6RJf1PiHoE9SHiFUiBnd++YsNByjGqn4Vxla1MlVdkaUMlvvc3/9oky4KfcRdQ +AK7L9cQqf7g6G4Ne5PNvosG3KaS009xSN1AAalTqS9eqrDYR2yZSixmUYqP57Jm5 +5ERI+pZi2aKpl0ONC7vKIyH+gxjsRPct4DKoxZnt4/KJYhINOlQz8i/j0jfqftmg +G1bYpbRRbfWoGHjGJA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRAPQAvihfjBg/JDbj6U64K98wDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIwMTYyODQxWhgPMjA2MTA1MjAxNzI4NDFa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +vJ9lgyksCxkBlY40qOzI1TCj/Q0FVGuPL/Z1Mw2YN0l+41BDv0FHApjTUkIKOeIP +nwDwpXTa3NjYbk3cOZ/fpH2rYJ++Fte6PNDGPgKppVCUh6x3jiVZ1L7wOgnTdK1Q +Trw8440IDS5eLykRHvz8OmwvYDl0iIrt832V0QyOlHTGt6ZJ/aTQKl12Fy3QBLv7 +stClPzvHTrgWqVU6uidSYoDtzHbU7Vda7YH0wD9IUoMBf7Tu0rqcE4uH47s2XYkc +SdLEoOg/Ngs7Y9B1y1GCyj3Ux7hnyvCoRTw014QyNB7dTatFMDvYlrRDGG14KeiU +UL7Vo/+EejWI31eXNLw84wIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBQkgTWFsNg6wA3HbbihDQ4vpt1E2zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAGz1Asiw7hn5WYUj8RpOCzpE0h/oBZcnxP8wulzZ5Xd0YxWO +0jYUcUk3tTQy1QvoY+Q5aCjg6vFv+oFBAxkib/SmZzp4xLisZIGlzpJQuAgRkwWA +6BVMgRS+AaOMQ6wKPgz1x4v6T0cIELZEPq3piGxvvqkcLZKdCaeC3wCS6sxuafzZ +4qA3zMwWuLOzRftgX2hQto7d/2YkRXga7jSvQl3id/EI+xrYoH6zIWgjdU1AUaNq +NGT7DIo47vVMfnd9HFZNhREsd4GJE83I+JhTqIxiKPNxrKgESzyADmNPt0gXDnHo +tbV1pMZz5HpJtjnP/qVZhEK5oB0tqlKPv9yx074= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICuTCCAj6gAwIBAgIRAKp1Rn3aL/g/6oiHVIXtCq8wCgYIKoZIzj0EAwMwgZsx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h +em9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MjQyMDMyMTdaGA8yMTIxMDUyNDIxMzIxN1owgZsx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h +em9uIFJEUyBhcC1ub3J0aGVhc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABGTYWPILeBJXfcL3Dz4z +EWMUq78xB1HpjBwHoTURYfcMd5r96BTVG6yaUBWnAVCMeeD6yTG9a1eVGNhG14Hk +ZAEjgLiNB7RRbEG5JZ/XV7W/vODh09WCst2y9SLKsdgeAaNCMEAwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUoE0qZHmDCDB+Bnm8GUa/evpfPwgwDgYDVR0PAQH/ +BAQDAgGGMAoGCCqGSM49BAMDA2kAMGYCMQCnil5MMwhY3qoXv0xvcKZGxGPaBV15 +0CCssCKn0oVtdJQfJQ3Jrf3RSaEyijXIJsoCMQC35iJi4cWoNX3N/qfgnHohW52O +B5dg0DYMqy5cNZ40+UcAanRMyqNQ6P7fy3umGco= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtzCCAj2gAwIBAgIQPXnDTPegvJrI98qz8WxrMjAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxODIxNDAxMloYDzIxMjEwNTE4MjI0MDEyWjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEI0sR7gwutK5AB46hM761 +gcLTGBIYlURSEoM1jcBwy56CL+3CJKZwLLyJ7qoOKfWbu5GsVLUTWS8MV6Nw33cx +2KQD2svb694wi+Px2f4n9+XHkEFQw8BbiodDD7RZA70fo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTQSioOvnVLEMXwNSDg+zgln/vAkjAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAMwu1hqm5Bc98uE/E0B5iMYbBQ4kpMxO +tP8FTfz5UR37HUn26nXE0puj6S/Ffj4oJgIwXI7s2c26tFQeqzq6u3lrNJHp5jC9 +Uxlo/hEJOLoDj5jnpxo8dMAtCNoQPaHdfL0P +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/jCCA+agAwIBAgIQEM1pS+bWfBJeu/6j1yIIFzANBgkqhkiG9w0BAQwFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIGNhLXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjMwOTE5MjIwMTM5WhgPMjEyMzA5MTkyMzAxMzlaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgY2Etd2VzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK2Pyp8p5z6HnlGB +daOj78gZ3ABufxnBFiu5NdFiGoMrS+eY//xxr2iKbnynJAzjmn5A6VKMNxtbuYIZ +WKAzDb/HrWlIYD2w7ZVBXpylfPhiz3jLNsl03WdPNnEruCcivhY2QMewEVtzjPU0 +ofdbZlO2KpF3biv1gjPuIuE7AUyQAbWnWTlrzETAVWLboJJRRqxASSkFUHNLXod7 +ow02FwlAhcnCp9gSe1SKRDrpvvEvYQBAFB7owfnoQzOGDdd87RGyYfyuW8aFI2Z0 +LHNvsA0dTafO4Rh986c72kDL7ijICQdr5OTgZR2OnuESLk1DSK4xYJ4fA6jb5dJ5 ++xsI6tCPykWCW98aO/pha35OsrVNifL/5cH5pdv/ecgQGdffJB+Vdj6f/ZMwR6s/ +Rm37cQ9l3tU8eu/qpzsFjLq1ZUzDaVDWgMW9t49+q/zjhdmbPOabZDao7nHXrVRw +rwPHWCmEY4OmH6ikEKQW3AChFjOdSg4me/J0Jr5l5jKggLPHWbNLRO8qTTK6N8qk +ui3aJDi+XQfsTPARXIw4UFErArNImTsoZVyqfX7I4shp0qZbEhP6kRAbfPljw5kW +Yat7ZlXqDanjsreqbLTaOU10P0rC0/4Ctv5cLSKCrzRLWtpXxhKa2wJTQ74G6fAZ +1oUA79qg3F8nyM+ZzDsfNI854+PNAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFLRWiDabEQZNkzEPUCr1ZVJV6xpwMA4GA1UdDwEB/wQEAwIBhjAN +BgkqhkiG9w0BAQwFAAOCAgEATkVVzkkGBjEtLGDtERi+fSpIV0MxwAsA4PAeBBmb +myxo90jz6kWkKM1Wm4BkZM8/mq5VbxPef1kxHfb5CHksCL6SgG5KujfIvht+KT2a +MRJB+III3CbcTy0HtwCX5AlPIbXWydhQFoJTW/OkpecUWoyFM6SqYeYZx1itJpxl +sXshLjYOvw+QgvxRsDxqUfkcaC/N2yhu/30Zo2P8msJfAFry2UmA/TBrWOQKVQxl +Ee/yWgp4U/bC/GZnjWnWDTwkRFGQtI4wjxbVuX6V4FTLCT7kIoHBhG+zOSduJRn3 +Axej7gkEXEVc/PAnwp/kSJ/b0/JONLWdjGUFkyiMn1yJlhJ2sg39vepBN5r6yVYU +nJWoZAuupRpoIKfmC3/cZanXqYbYl4yxzX/PMB4kAACfdxGxLawjnnBjSzaWokXs +YVh2TjWpUMwLOi0RB2mtPUjHdDLKtjOTZ1zHZnR/wVp9BmVI1BXYnz5PAqU5XqeD +EmanyaAuFCeyol1EtbQhgtysThQ+vwYAXMm2iKzJxq0hik8wyG8X55FhnGEOGV3u +xxq7odd3/8BXkc3dGdBPQtH+k5glaQyPnAsLVAIUvyzTmy58saL+nJnQY4mmRrwV +1jJA7nnkaklI/L5fvfCg0W+TMinCOAGd+GQ4hK2SAsJLtcqiBgPf2wJHO8wiwUh9 +Luw= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjWgAwIBAgIQGKVv+5VuzEZEBzJ+bVfx2zAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGFwLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTE5MTc1MDU5WhgPMjEyMTA1MTkxODUwNTlaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgYXAtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABMqdLJ0tZF/DGFZTKZDrGRJZID8ivC2I +JRCYTWweZKCKSCAzoiuGGHzJhr5RlLHQf/QgmFcgXsdmO2n3CggzhA4tOD9Ip7Lk +P05eHd2UPInyPCHRgmGjGb0Z+RdQ6zkitKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUC1yhRgVqU5bR8cGzOUCIxRpl4EYwDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2cAMGQCMG0c/zLGECRPzGKJvYCkpFTCUvdP4J74YP0v/dPvKojL +t/BrR1Tg4xlfhaib7hPc7wIwFvgqHes20CubQnZmswbTKLUrgSUW4/lcKFpouFd2 +t2/ewfi/0VhkeUW+IiHhOMdU +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAOXxJuyXVkbfhZCkS/dOpfEwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI1MjE1OTEwWhgPMjEyMTA1MjUyMjU5MTBa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +xiP4RDYm4tIS12hGgn1csfO8onQDmK5SZDswUpl0HIKXOUVVWkHNlINkVxbdqpqH +FhbyZmNN6F/EWopotMDKe1B+NLrjNQf4zefv2vyKvPHJXhxoKmfyuTd5Wk8k1F7I +lNwLQzznB+ElhrLIDJl9Ro8t31YBBNFRGAGEnxyACFGcdkjlsa52UwfYrwreEg2l +gW5AzqHgjFfj9QRLydeU/n4bHm0F1adMsV7P3rVwilcUlqsENDwXnWyPEyv3sw6F +wNemLEs1129mB77fwvySb+lLNGsnzr8w4wdioZ74co+T9z2ca+eUiP+EQccVw1Is +D4Fh57IjPa6Wuc4mwiUYKkKY63+38aCfEWb0Qoi+zW+mE9nek6MOQ914cN12u5LX +dBoYopphRO5YmubSN4xcBy405nIdSdbrAVWwxXnVVyjqjknmNeqQsPZaxAhdoKhV +AqxNr8AUAdOAO6Sz3MslmcLlDXFihrEEOeUbpg/m1mSUUHGbu966ajTG1FuEHHwS +7WB52yxoJo/tHvt9nAWnh3uH5BHmS8zn6s6CGweWKbX5yICnZ1QFR1e4pogxX39v +XD6YcNOO+Vn+HY4nXmjgSYVC7l+eeP8eduMg1xJujzjrbmrXU+d+cBObgdTOAlpa +JFHaGwYw1osAwPCo9cZ2f04yitBfj9aPFia8ASKldakCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUqKS+ltlior0SyZKYAkJ/efv55towDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQAdElvp8bW4B+Cv+1WSN87dg6TN +wGyIjJ14/QYURgyrZiYpUmZpj+/pJmprSWXu4KNyqHftmaidu7cdjL5nCAvAfnY5 +/6eDDbX4j8Gt9fb/6H9y0O0dn3mUPSEKG0crR+JRFAtPhn/2FNvst2P82yguWLv0 +pHjHVUVcq+HqDMtUIJsTPYjSh9Iy77Q6TOZKln9dyDOWJpCSkiUWQtMAKbCSlvzd +zTs/ahqpT+zLfGR1SR+T3snZHgQnbnemmz/XtlKl52NxccARwfcEEKaCRQyGq/pR +0PVZasyJS9JY4JfQs4YOdeOt4UMZ8BmW1+BQWGSkkb0QIRl8CszoKofucAlqdPcO +IT/ZaMVhI580LFGWiQIizWFskX6lqbCyHqJB3LDl8gJISB5vNTHOHpvpMOMs5PYt +cRl5Mrksx5MKMqG7y5R734nMlZxQIHjL5FOoOxTBp9KeWIL/Ib89T2QDaLw1SQ+w +ihqWBJ4ZdrIMWYpP3WqM+MXWk7WAem+xsFJdR+MDgOOuobVQTy5dGBlPks/6gpjm +rO9TjfQ36ppJ3b7LdKUPeRfnYmlR5RU4oyYJ//uLbClI443RZAgxaCXX/nyc12lr +eVLUMNF2abLX4/VF63m2/Z9ACgMRfqGshPssn1NN33OonrotQoj4S3N9ZrjvzKt8 +iHcaqd60QKpfiH2A3A== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrTCCAjOgAwIBAgIQOsXM3iPpLzFtjuRCpLsrlDAKBggqhkjOPQQDAzCBljEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6 +b24gUkRTIGFwLWVhc3QtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTAgFw0yNTAyMDEwMDEwMzFaGA8yMTI1MDIwMTAxMTAzMVowgZYxCzAJBgNV +BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD +VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE +UyBhcC1lYXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQ+7GUElCbaFRRjg6gT4QTBuontLCF4sLso +/sHZ60qQI8SVEsDTF7C3zebh+VlNtPhgF1Le61HW86KlDQYf49pVN2guXBrdf3qb +RQCwSHSKs4S2bMyRMZ6xjwoGOrEKFEKjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFKc0lLMy1OaSxQ+rtD7tyY3bFlv5MA4GA1UdDwEB/wQEAwIBhjAKBggq +hkjOPQQDAwNoADBlAjEA5Rhu3YN+PyIHg3NXSw1DbowJIHNZgnieTD4BPkZLy4rs +8le5S3JjQQGyh+bsJf7RAjBPUMBF4CJDLE5nmetgXLlOl3oXwd/gHTls7YDr+R18 +cTBI0+RV4Z6yT5p1OF9AcnA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICuDCCAj2gAwIBAgIQPaVGRuu86nh/ylZVCLB0MzAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLW5vcnRoZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNTIyMDMxNloYDzIxMjEwNTI1MjMwMzE2WjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLW5vcnRoZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEexNURoB9KE93MEtEAlJG +obz4LS/pD2hc8Gczix1WhVvpJ8bN5zCDXaKdnDMCebetyRQsmQ2LYlfmCwpZwSDu +0zowB11Pt3I5Avu2EEcuKTlKIDMBeZ1WWuOd3Tf7MEAMo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBSaYbZPBvFLikSAjpa8mRJvyArMxzAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaQAwZgIxAOEJkuh3Zjb7Ih/zuNRd1RBqmIYcnyw0 +nwUZczKXry+9XebYj3VQxSRNadrarPWVqgIxAMg1dyGoDAYjY/L/9YElyMnvHltO +PwpJShmqHvCLc/mXMgjjYb/akK7yGthvW6j/uQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCDCCA/CgAwIBAgIQChu3v5W1Doil3v6pgRIcVzANBgkqhkiG9w0BAQwFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIEJldGEgdXMtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yMTA1MTgyMTM0MTVaGA8yMTIxMDUxODIyMzQxNVow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBCZXRhIHVzLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC1 +FUGQ5tf3OwpDR6hGBxhUcrkwKZhaXP+1St1lSOQvjG8wXT3RkKzRGMvb7Ee0kzqI +mzKKe4ASIhtV3UUWdlNmP0EA3XKnif6N79MismTeGkDj75Yzp5A6tSvqByCgxIjK +JqpJrch3Dszoyn8+XhwDxMZtkUa5nQVdJgPzJ6ltsQ8E4SWLyLtTu0S63jJDkqYY +S7cQblk7y7fel+Vn+LS5dGTdRRhMvSzEnb6mkVBaVzRyVX90FNUED06e8q+gU8Ob +htvQlf9/kRzHwRAdls2YBhH40ZeyhpUC7vdtPwlmIyvW5CZ/QiG0yglixnL6xahL +pbmTuTSA/Oqz4UGQZv2WzHe1lD2gRHhtFX2poQZeNQX8wO9IcUhrH5XurW/G9Xwl +Sat9CMPERQn4KC3HSkat4ir2xaEUrjfg6c4XsGyh2Pk/LZ0gLKum0dyWYpWP4JmM +RQNjrInXPbMhzQObozCyFT7jYegS/3cppdyy+K1K7434wzQGLU1gYXDKFnXwkX8R +bRKgx2pHNbH5lUddjnNt75+e8m83ygSq/ZNBUz2Ur6W2s0pl6aBjwaDES4VfWYlI +jokcmrGvJNDfQWygb1k00eF2bzNeNCHwgWsuo3HSxVgc/WGsbcGrTlDKfz+g3ich +bXUeUidPhRiv5UQIVCLIHpHuin3bj9lQO/0t6p+tAQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBSFmMBgm5IsRv3hLrvDPIhcPweXYTAOBgNVHQ8B +Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAAa2EuozymOsQDJlEi7TqnyA2OhT +GXPfYqCyMJVkfrqNgcnsNpCAiNEiZbb+8sIPXnT8Ay8hrwJYEObJ5b7MHXpLuyft +z0Pu1oFLKnQxKjNxrIsCvaB4CRRdYjm1q7EqGhMGv76se9stOxkOqO9it31w/LoU +ENDk7GLsSqsV1OzYLhaH8t+MaNP6rZTSNuPrHwbV3CtBFl2TAZ7iKgKOhdFz1Hh9 +Pez0lG+oKi4mHZ7ajov6PD0W7njn5KqzCAkJR6OYmlNVPjir+c/vUtEs0j+owsMl +g7KE5g4ZpTRShyh5BjCFRK2tv0tkqafzNtxrKC5XNpEkqqVTCnLcKG+OplIEadtr +C7UWf4HyhCiR+xIyxFyR05p3uY/QQU/5uza7GlK0J+U1sBUytx7BZ+Fo8KQfPPqV +CqDCaYUksoJcnJE/KeoksyqNQys7sDGJhkd0NeUGDrFLKHSLhIwAMbEWnqGxvhli +E7sP2E5rI/I9Y9zTbLIiI8pfeZlFF8DBdoP/Hzg8pqsiE/yiXSFTKByDwKzGwNqz +F0VoFdIZcIbLdDbzlQitgGpJtvEL7HseB0WH7B2PMMD8KPJlYvPveO3/6OLzCsav ++CAkvk47NQViKMsUTKOA0JDCW+u981YRozxa3K081snhSiSe83zIPBz1ikldXxO9 +6YYLNPRrj3mi9T/f +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAMkvdFnVDb0mWWFiXqnKH68wCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyB1cy13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTE5MTkxMzI0WhgPMjEyMTA1MTkyMDEzMjRaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgdXMtd2VzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEy86DB+9th/0A5VcWqMSWDxIUblWTt/R0 +ao6Z2l3vf2YDF2wt1A2NIOGpfQ5+WAOJO/IQmnV9LhYo+kacB8sOnXdQa6biZZkR +IyouUfikVQAKWEJnh1Cuo5YMM4E2sUt5o0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBQ8u3OnecANmG8OoT7KLWDuFzZwBTAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIwQ817qkb7mWJFnieRAN+m9W3E0FLVKaV3zC5aYJUk2fcZ +TaUx3oLp3jPLGvY5+wgeAjEA6wAicAki4ZiDfxvAIuYiIe1OS/7H5RA++R8BH6qG +iRzUBM/FItFpnkus7u/eTkvo +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrzCCAjWgAwIBAgIQS/+Ryfgb/IOVEa1pWoe8oTAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGFwLXNvdXRoLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjIwNjA2MjE1NDQyWhgPMjEyMjA2MDYyMjU0NDJaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgYXAtc291dGgtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDsX6fhdUWBQpYTdseBD/P3s96Dtw2Iw +OrXKNToCnmX5nMkUGdRn9qKNiz1pw3EPzaPxShbYwQ7LYP09ENK/JN4QQjxMihxC +jLFxS85nhBQQQGRCWikDAe38mD8fSvREQKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUIh1xZiseQYFjPYKJmGbruAgRH+AwDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2gAMGUCMFudS4zLy+UUGrtgNLtRMcu/DZ9BUzV4NdHxo0bkG44O +thnjl4+wTKI6VbyAbj2rkgIxAOHps8NMITU5DpyiMnKTxV8ubb/WGHrLl0BjB8Lw +ETVJk5DNuZvsIIcm7ykk6iL4Tw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBDCCA+ygAwIBAgIQDcEmNIAVrDpUw5cH5ynutDANBgkqhkiG9w0BAQwFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIG1lLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjIwNTA3MDA0MDIzWhgPMjEyMjA1MDcwMTQwMjNaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKvADk8t +Fl9bFlU5sajLPPDSOUpPAkKs6iPlz+27o1GJC88THcOvf3x0nVAcu9WYe9Qaas+4 +j4a0vv51agqyODRD/SNi2HnqW7DbtLPAm6KBHe4twl28ItB/JD5g7u1oPAHFoXMS +cH1CZEAs5RtlZGzJhcBXLFsHNv/7+SCLyZ7+2XFh9OrtgU4wMzkHoRNndhfwV5bu +17bPTwuH+VxH37zXf1mQ/KjhuJos0C9dL0FpjYBAuyZTAWhZKs8dpSe4DI544z4w +gkwUB4bC2nA1TBzsywEAHyNuZ/xRjNpWvx0ToWAA2iFJqC3VO3iKcnBplMvaUuMt +jwzVSNBnKcoabXCZL2XDLt4YTZR8FSwz05IvsmwcPB7uNTBXq3T9sjejW8QQK3vT +tzyfLq4jKmQE7PoS6cqYm+hEPm2hDaC/WP9bp3FdEJxZlPH26fq1b7BWYWhQ9pBA +Nv9zTnzdR1xohTyOJBUFQ81ybEzabqXqVXUIANqIOaNcTB09/sLJ7+zuMhp3mwBu +LtjfJv8PLuT1r63bU3seROhKA98b5KfzjvbvPSg3vws78JQyoYGbqNyDfyjVjg3U +v//AdVuPie6PNtdrW3upZY4Qti5IjP9e3kimaJ+KAtTgMRG56W0WxD3SP7+YGGbG +KhntDOkKsN39hLpn9UOafTIqFu7kIaueEy/NAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFHAems86dTwdZbLe8AaPy3kfIUVoMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAOBHpp0ICx81kmeoBcZTrMdJs2gnhcd85 +FoSCjXx9H5XE5rmN/lQcxxOgj8hr3uPuLdLHu+i6THAyzjrl2NA1FWiqpfeECGmy +0jm7iZsYORgGQYp/VKnDrwnKNSqlZvOuRr0kfUexwFlr34Y4VmupvEOK/RdGsd3S ++3hiemcHse9ST/sJLHx962AWMkN86UHPscJEe4+eT3f2Wyzg6La8ARwdWZSNS+WH +ZfybrncMmuiXuUdHv9XspPsqhKgtHhcYeXOGUtrwQPLe3+VJZ0LVxhlTWr9951GZ +GfmWwTV/9VsyKVaCFIXeQ6L+gjcKyEzYF8wpMtQlSc7FFqwgC4bKxvMBSaRy88Nr +lV2+tJD/fr8zGUeBK44Emon0HKDBWGX+/Hq1ZIv0Da0S+j6LbA4fusWxtGfuGha+ +luhHgVInCpALIOamiBEdGhILkoTtx7JrYppt3/Raqg9gUNCOOYlCvGhqX7DXeEfL +DGabooiY2FNWot6h04JE9nqGj5QqT8D6t/TL1nzxhRPzbcSDIHUd/b5R+a0bAA+7 +YTU6JqzEVCWKEIEynYmqikgLMGB/OzWsgyEL6822QW6hJAQ78XpbNeCzrICF4+GC +7KShLnwuWoWpAb26268lvOEvCTFM47VC6jNQl97md+2SA9Ma81C9wflid2M83Wle +cuLMVcQZceE= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQAhAteLRCvizAElaWORFU2zANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMDE3MDkxNloYDzIwNjEwNTIwMTgwOTE2WjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+qg7JAcOVKjh +N83SACnBFZPyB63EusfDr/0V9ZdL8lKcmZX9sv/CqoBo3N0EvBqHQqUUX6JvFb7F +XrMUZ740kr28gSRALfXTFgNODjXeDsCtEkKRTkac/UM8xXHn+hR7UFRPHS3e0GzI +iLiwQWDkr0Op74W8aM0CfaVKvh2bp4BI1jJbdDnQ9OKXpOxNHGUf0ZGb7TkNPkgI +b2CBAc8J5o3H9lfw4uiyvl6Fz5JoP+A+zPELAioYBXDrbE7wJeqQDJrETWqR9VEK +BXURCkVnHeaJy123MpAX2ozf4pqk0V0LOEOZRS29I+USF5DcWr7QIXR/w2I8ws1Q +7ys+qbE+kQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQFJ16n +1EcCMOIhoZs/F9sR+Jy++zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAOc5nXbT3XTDEZsxX2iD15YrQvmL5m13B3ImZWpx/pqmObsgx3/dg75rF2nQ +qS+Vl+f/HLh516pj2BPP/yWCq12TRYigGav8UH0qdT3CAClYy2o+zAzUJHm84oiB +ud+6pFVGkbqpsY+QMpJUbZWu52KViBpJMYsUEy+9cnPSFRVuRAHjYynSiLk2ZEjb +Wkdc4x0nOZR5tP0FgrX0Ve2KcjFwVQJVZLgOUqmFYQ/G0TIIGTNh9tcmR7yp+xJR +A2tbPV2Z6m9Yxx4E8lLEPNuoeouJ/GR4CkMEmF8cLwM310t174o3lKKUXJ4Vs2HO +Wj2uN6R9oI+jGLMSswTzCNV1vgc= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtjCCAj2gAwIBAgIQM+ObZzo0HZj7HpGdeMmx/zAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC01IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTI0MDUxNTIxNTA0NloYDzIxMjQwNTE1MjI1MDQ2WjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC01IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEhSrJY/MXuQyTqK1dnLK6 +uWUx/KxsGCMCBXKthi0spP90CjfOYYxDcGD7zgUtk+LCEK2vneuewAPhlUgqXzaZ +PYDzk2WUznIPiIBvVo32U4vUnV/vSWqzhSKevsOakiPso0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBRw/PJZ4fwnZo25vVSB80KtyKWqmTAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDZwAwZAIwLNcaZNOvCLTumHlJydm+9lB6bcxnaLmb +esoToveXQABKl84kGNI1gaDKOvvLsPbWAjBIqfDMb83RXw7q2C501W5hzsbZ1ZQs +8+tffIuCrMMGWDLqoUksWJHiocLOfe9gwm4= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICuDCCAj6gAwIBAgIRAOocLeZWjYkG/EbHmscuy8gwCgYIKoZIzj0EAwMwgZsx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h +em9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MjEyMTUwMDFaGA8yMTIxMDUyMTIyNTAwMVowgZsx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE0MDIGA1UEAwwrQW1h +em9uIFJEUyBhcC1zb3V0aGVhc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABCEr3jq1KtRncnZfK5cq +btY0nW6ZG3FMbh7XwBIR6Ca0f8llGZ4vJEC1pXgiM/4Dh045B9ZIzNrR54rYOIfa +2NcYZ7mk06DjIQML64hbAxbQzOAuNzLPx268MrlL2uW2XaNCMEAwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQUln75pChychwN4RfHl+tOinMrfVowDgYDVR0PAQH/ +BAQDAgGGMAoGCCqGSM49BAMDA2gAMGUCMGiyPINRU1mwZ4Crw01vpuPvxZxb2IOr +yX3RNlOIu4We1H+5dQk5tIvH8KGYFbWEpAIxAO9NZ6/j9osMhLgZ0yj0WVjb+uZx +YlZR9fyFisY/jNfX7QhSk+nrc3SFLRUNtpXrng== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBTCCAu2gAwIBAgIRAKiaRZatN8eiz9p0s0lu0rQwDQYJKoZIhvcNAQELBQAw +gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq +QW1hem9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD +VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMDIzNVoYDzIwNjEwNTIxMjMwMjM1WjCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGNhLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCygVMf +qB865IR9qYRBRFHn4eAqGJOCFx+UbraQZmjr/mnRqSkY+nhbM7Pn/DWOrRnxoh+w +q5F9ZxdZ5D5T1v6kljVwxyfFgHItyyyIL0YS7e2h7cRRscCM+75kMedAP7icb4YN +LfWBqfKHbHIOqvvQK8T6+Emu/QlG2B5LvuErrop9K0KinhITekpVIO4HCN61cuOe +CADBKF/5uUJHwS9pWw3uUbpGUwsLBuhJzCY/OpJlDqC8Y9aToi2Ivl5u3/Q/sKjr +6AZb9lx4q3J2z7tJDrm5MHYwV74elGSXoeoG8nODUqjgklIWAPrt6lQ3WJpO2kug +8RhCdSbWkcXHfX95AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FOIxhqTPkKVqKBZvMWtKewKWDvDBMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B +AQsFAAOCAQEAqoItII89lOl4TKvg0I1EinxafZLXIheLcdGCxpjRxlZ9QMQUN3yb +y/8uFKBL0otbQgJEoGhxm4h0tp54g28M6TN1U0332dwkjYxUNwvzrMaV5Na55I2Z +1hq4GB3NMXW+PvdtsgVOZbEN+zOyOZ5MvJHEQVkT3YRnf6avsdntltcRzHJ16pJc +Y8rR7yWwPXh1lPaPkxddrCtwayyGxNbNmRybjR48uHRhwu7v2WuAMdChL8H8bp89 +TQLMrMHgSbZfee9hKhO4Zebelf1/cslRSrhkG0ESq6G5MUINj6lMg2g6F0F7Xz2v +ncD/vuRN5P+vT8th/oZ0Q2Gc68Pun0cn/g== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAJYlnmkGRj4ju/2jBQsnXJYwDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyB1cy1lYXN0LTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMTIzMDQ0NFoYDzIwNjEwNTIyMDAwNDQ0WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIHVzLWVhc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC74V3eigv+pCj5 +nqDBqplY0Jp16pTeNB06IKbzb4MOTvNde6QjsZxrE1xUmprT8LxQqN9tI3aDYEYk +b9v4F99WtQVgCv3Y34tYKX9NwWQgwS1vQwnIR8zOFBYqsAsHEkeJuSqAB12AYUSd +Zv2RVFjiFmYJho2X30IrSLQfS/IE3KV7fCyMMm154+/K1Z2IJlcissydEAwgsUHw +edrE6CxJVkkJ3EvIgG4ugK/suxd8eEMztaQYJwSdN8TdfT59LFuSPl7zmF3fIBdJ +//WexcQmGabaJ7Xnx+6o2HTfkP8Zzzzaq8fvjAcvA7gyFH5EP26G2ZqMG+0y4pTx +SPVTrQEXAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFIWWuNEF +sUMOC82XlfJeqazzrkPDMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAgClmxcJaQTGpEZmjElL8G2Zc8lGc+ylGjiNlSIw8X25/bcLRptbDA90nuP+q +zXAMhEf0ccbdpwxG/P5a8JipmHgqQLHfpkvaXx+0CuP++3k+chAJ3Gk5XtY587jX ++MJfrPgjFt7vmMaKmynndf+NaIJAYczjhJj6xjPWmGrjM3MlTa9XesmelMwP3jep +bApIWAvCYVjGndbK9byyMq1nyj0TUzB8oJZQooaR3MMjHTmADuVBylWzkRMxbKPl +4Nlsk4Ef1JvIWBCzsMt+X17nuKfEatRfp3c9tbpGlAE/DSP0W2/Lnayxr4RpE9ds +ICF35uSis/7ZlsftODUe8wtpkQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIQTcWg4evi5wHEIHLNrHCqQjANBgkqhkiG9w0BAQsFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC03IFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yNDA5MTIxNTU0MzFaGA8yMDY0MDkxMjE2NTQzMVow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNyBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCS +X21nYtSipOhpdF6QfLE4BTwbtrXCR3EBhOHB1MfwwjNh25uex3X/gHW/sUTgC/oe +Oboi+3I/HN6a5MneTcVVps8AL8rJGym0ShSIYza/3MyT+PtfqDNmYfmF8VRIhNSR +CoYW93F/BoZF7bFk4ljOrBSrRbfb1qmEtkTPNGBRnJ0jh05Fwq5a5XnkGcOGNDyH +kGt9ZaUm+cJmzAa1omHspCjgg6854CNs4k5ovT2vaZ/0yAogdSpkB9INLM0ZMqJ+ +QoKAyN6hdISjwEEZrl+0OKymc2jR+NpQcd3378bDIfg3iYvuvTp84kKpZ4gRaukm +mSUg/PQqmGdBlAivOiknAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFDsAilCg2DzFiZheGlKVb74AubP2MA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG +9w0BAQsFAAOCAQEACUGKd2GVv87xnVnPajqbi7GkMd+XGwyG1P8Nkon9rfUgwgHR +dHDsO6jIKf3ZEzNcMgMyV5sXs2944WRhGkYxQ62wbkEaqtjNExmlmUiS3vvrGOfg +BmRhJvAOM5HbNMqmi5BC4GlhHWQKhRbStwhvbVYnARBVfR/Wz4Qb4fizaXbuNWAo +V0XBQc+67lto2eBQ84KQXTp60FdwSBzltbM7sZmp6dMNgnfPNUrdxurVboF+VsxS +3aUJnmEi6TJAGL2SXLermB6HiTgxtxOJUiefu4Ipv0JEg41OzbKWsJXTaSH0QDb1 +nSeMyoXqF3ixcAhQbw/7NrzMMlf3NmMaVlohVA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjOgAwIBAgIQS7vMpOTVq2Jw457NdZ2ffjAKBggqhkjOPQQDAzCBljEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6 +b24gUkRTIGNhLXdlc3QtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTAgFw0yMzA5MTkyMjExNDNaGA8yMTIzMDkxOTIzMTE0M1owgZYxCzAJBgNV +BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD +VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE +UyBjYS13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAARdgGSs/F2lpWKqS1ZpcmatFED1JurmNbXG +Sqhv1A/geHrKCS15MPwjtnfZiujYKY4fNkCCUseoGDwkC4281nwkokvnfWR1/cXy +LxfACoXNxsI4b+37CezSUBl48/5p1/OjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFFhLokGBuJGwKJhZcYSYKyZIitJtMA4GA1UdDwEB/wQEAwIBhjAKBggq +hkjOPQQDAwNpADBmAjEA8aQQlzJRHbqFsRY4O3u/cN0T8dzjcqnYn4NV1w+jvhzt +QPJLB+ggGyQhoFR6G2UrAjEA0be8OP5MWXD8d01KKbo5Dpy6TwukF5qoJmkFJKS3 +bKfEMvFWxXoV06HNZFWdI80u +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRAPvvd+MCcp8E36lHziv0xhMwDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyB1cy1lYXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMTIzMTEwNloYDzIxMjEwNTIyMDAxMTA2WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIHVzLWVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDbvwekKIKGcV/s +lDU96a71ZdN2pTYkev1X2e2/ICb765fw/i1jP9MwCzs8/xHBEQBJSxdfO4hPeNx3 +ENi0zbM+TrMKliS1kFVe1trTTEaHYjF8BMK9yTY0VgSpWiGxGwg4tshezIA5lpu8 +sF6XMRxosCEVCxD/44CFqGZTzZaREIvvFPDTXKJ6yOYnuEkhH3OcoOajHN2GEMMQ +ShuyRFDQvYkqOC/Q5icqFbKg7eGwfl4PmimdV7gOVsxSlw2s/0EeeIILXtHx22z3 +8QBhX25Lrq2rMuaGcD3IOMBeBo2d//YuEtd9J+LGXL9AeOXHAwpvInywJKAtXTMq +Wsy3LjhuANFrzMlzjR2YdjkGVzeQVx3dKUzJ2//Qf7IXPSPaEGmcgbxuatxjnvfT +H85oeKr3udKnXm0Kh7CLXeqJB5ITsvxI+Qq2iXtYCc+goHNR01QJwtGDSzuIMj3K +f+YMrqBXZgYBwU2J/kCNTH31nfw96WTbOfNGwLwmVRDgguzFa+QzmQsJW4FTDMwc +7cIjwdElQQVA+Gqa67uWmyDKAnoTkudmgAP+OTBkhnmc6NJuZDcy6f/iWUdl0X0u +/tsfgXXR6ZovnHonM13ANiN7VmEVqFlEMa0VVmc09m+2FYjjlk8F9sC7Rc4wt214 +7u5YvCiCsFZwx44baP5viyRZgkJVpQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBQgCZCsc34nVTRbWsniXBPjnUTQ2DAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBAAQas3x1G6OpsIvQeMS9BbiHG3+kU9P/ba6Rrg+E +lUz8TmL04Bcd+I+R0IyMBww4NznT+K60cFdk+1iSmT8Q55bpqRekyhcdWda1Qu0r +JiTi7zz+3w2v66akofOnGevDpo/ilXGvCUJiLOBnHIF0izUqzvfczaMZGJT6xzKq +PcEVRyAN1IHHf5KnGzUlVFv9SGy47xJ9I1vTk24JU0LWkSLzMMoxiUudVmHSqJtN +u0h+n/x3Q6XguZi1/C1KOntH56ewRh8n5AF7c+9LJJSRM9wunb0Dzl7BEy21Xe9q +03xRYjf5wn8eDELB8FZPa1PrNKXIOLYM9egdctbKEcpSsse060+tkyBrl507+SJT +04lvJ4tcKjZFqxn+bUkDQvXYj0D3WK+iJ7a8kZJPRvz8BDHfIqancY8Tgw+69SUn +WqIb+HNZqFuRs16WFSzlMksqzXv6wcDSyI7aZOmCGGEcYW9NHk8EuOnOQ+1UMT9C +Qb1GJcipjRzry3M4KN/t5vN3hIetB+/PhmgTO4gKhBETTEyPC3HC1QbdVfRndB6e +U/NF2U/t8U2GvD26TTFLK4pScW7gyw4FQyXWs8g8FS8f+R2yWajhtS9++VDJQKom +fAUISoCH+PlPRJpu/nHd1Zrddeiiis53rBaLbXu2J1Q3VqjWOmtj0HjxJJxWnYmz +Pqj2 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAI/U4z6+GF8/znpHM8Dq8G0wDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMjA2MDYyMTQ4MThaGA8yMTIyMDYwNjIyNDgxOFowgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBhcC1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK5WqMvyq888 +3uuOtEj1FcP6iZhqO5kJurdJF59Otp2WCg+zv6I+QwaAspEWHQsKD405XfFsTGKV +SKTCwoMxwBniuChSmyhlagQGKSnRY9+znOWq0v7hgmJRwp6FqclTbubmr+K6lzPy +hs86mEp68O5TcOTYWUlPZDqfKwfNTbtCl5YDRr8Gxb5buHmkp6gUSgDkRsXiZ5VV +b3GBmXRqbnwo5ZRNAzQeM6ylXCn4jKs310lQGUrFbrJqlyxUdfxzqdlaIRn2X+HY +xRSYbHox3LVNPpJxYSBRvpQVFSy9xbX8d1v6OM8+xluB31cbLBtm08KqPFuqx+cO +I2H5F0CYqYzhyOSKJsiOEJT6/uH4ewryskZzncx9ae62SC+bB5n3aJLmOSTkKLFY +YS5IsmDT2m3iMgzsJNUKVoCx2zihAzgBanFFBsG+Xmoq0aKseZUI6vd2qpd5tUST +/wS1sNk0Ph7teWB2ACgbFE6etnJ6stwjHFZOj/iTYhlnR2zDRU8akunFdGb6CB4/ +hMxGJxaqXSJeGtHm7FpadlUTf+2ESbYcVW+ui/F8sdBJseQdKZf3VdZZMgM0bcaX +NE47cauDTy72WdU9YJX/YXKYMLDE0iFHTnGpfVGsuWGPYhlwZ3dFIO07mWnCRM6X +u5JXRB1oy5n5HRluMsmpSN/R92MeBxKFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFNtH0F0xfijSLHEyIkRGD9gW6NazMA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEACo+5jFeY3ygxoDDzL3xpfe5M0U1WxdKk+az4 +/OfjZvkoma7WfChi3IIMtwtKLYC2/seKWA4KjlB3rlTsCVNPnK6D+gAnybcfTKk/ +IRSPk92zagwQkSUWtAk80HpVfWJzpkSU16ejiajhedzOBRtg6BwsbSqLCDXb8hXr +eXWC1S9ZceGc+LcKRHewGWPu31JDhHE9bNcl9BFSAS0lYVZqxIRWxivZ+45j5uQv +wPrC8ggqsdU3K8quV6dblUQzzA8gKbXJpCzXZihkPrYpQHTH0szvXvgebh+CNUAG +rUxm8+yTS0NFI3U+RLbcLFVzSvjMOnEwCX0SPj5XZRYYXs5ajtQCoZhTUkkwpDV8 +RxXk8qGKiXwUxDO8GRvmvM82IOiXz5w2jy/h7b7soyIgdYiUydMq4Ja4ogB/xPZa +gf4y0o+bremO15HFf1MkaU2UxPK5FFVUds05pKvpSIaQWbF5lw4LHHj4ZtVup7zF +CLjPWs4Hs/oUkxLMqQDw0FBwlqa4uot8ItT8uq5BFpz196ZZ+4WXw5PVzfSxZibI +C/nwcj0AS6qharXOs8yPnPFLPSZ7BbmWzFDgo3tpglRqo3LbSPsiZR+sLeivqydr +0w4RK1btRda5Ws88uZMmW7+2aufposMKcbAdrApDEAVzHijbB/nolS5nsnFPHZoA +KDPtFEk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtzCCAj2gAwIBAgIQVZ5Y/KqjR4XLou8MCD5pOjAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC00IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIyMDUyNTE2NTgzM1oYDzIxMjIwNTI1MTc1ODMzWjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC00IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEbo473OmpD5vkckdJajXg +brhmNFyoSa0WCY1njuZC2zMFp3zP6rX4I1r3imrYnJd9pFH/aSiV/r6L5ACE5RPx +4qdg5SQ7JJUaZc3DWsTOiOed7BCZSzM+KTYK/2QzDMApo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTmogc06+1knsej1ltKUOdWFvwgsjAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAIs7TlLMbGTWNXpGiKf9DxaM07d/iDHe +F/Vv/wyWSTGdobxBL6iArQNVXz0Gr4dvPAIwd0rsoa6R0x5mtvhdRPtM37FYrbHJ +pbV+OMusQqcSLseunLBoCHenvJW0QOCQ8EDY +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBTCCA+2gAwIBAgIRAO9dVdiLTEGO8kjUFExJmgowDQYJKoZIhvcNAQEMBQAw +gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq +QW1hem9uIFJEUyBpbC1jZW50cmFsLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYD +VQQHDAdTZWF0dGxlMCAXDTIyMTIwMjIwMjYwOFoYDzIxMjIxMjAyMjEyNjA4WjCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDkVHmJ +bUc8CNDGBcgPmXHSHj5dS1PDnnpk3doCu6pahyYXW8tqAOmOqsDuNz48exY7YVy4 +u9I9OPBeTYB9ZUKwxq+1ZNLsr1cwVz5DdOyDREVFOjlU4rvw0eTgzhP5yw/d+Ai/ ++WmPebZG0irwPKN2f60W/KJ45UNtR+30MT8ugfnPuSHWjjV+dqCOCp/mj8nOCckn +k8GoREwjuTFJMKInpQUC0BaVVX6LiIdgtoLY4wdx00EqNBuROoRTAvrked0jvm7J +UI39CSYxhNZJ9F6LdESZXjI4u2apfNQeSoy6WptxFHr+kh2yss1B2KT6lbwGjwWm +l9HODk9kbBNSy2NeewAms36q+p8wSLPavL28IRfK0UaBAiN1hr2a/2RDGCwOJmw6 +5erRC5IIX5kCStyXPEGhVPp18EvMuBd37eLIxjZBBO8AIDf4Ue8QmxSeZH0cT204 +3/Bd6XR6+Up9iMTxkHr1URcL1AR8Zd62lg/lbEfxePNMK9mQGxKP8eTMG5AjtW9G +TatEoRclgE0wZQalXHmKpBNshyYdGqQZhzL1MxCxWzfHNgZkTKIsdzxrjnP7RiBR +jdRH0YhXn6Y906QfLwMCaufwfQ5J8+nj/tu7nG138kSxsu6VUkhnQJhUcUsxuHD/ +NnBx0KGVEldtZiZf7ccgtRVp1lA0OrVtq3ZLMQIDAQABo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBQ2WC3p8rWeE2N0S4Om01KsNLpk/jAOBgNVHQ8BAf8E +BAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAFFEVDt45Obr6Ax9E4RMgsKjj4QjMFB9 +wHev1jL7hezl/ULrHuWxjIusaIZEIcKfn+v2aWtqOq13P3ht7jV5KsV29CmFuCdQ +q3PWiAXVs+hnMskTOmGMDnptqd6/UuSIha8mlOKKAvnmRQJvfX9hIfb/b/mVyKWD +uvTTmcy3cOTJY5ZIWGyzuvmcqA0YNcb7rkJt/iaLq4RX3/ofq4y4w36hefbcvj++ +pXHOmXk3dAej3y6SMBOUcGMyCJcCluRPNYKDTLn+fitcPxPC3JG7fI5bxQ0D6Hpa +qbyGBQu96sfahQyMc+//H8EYlo4b0vPeS5RFFXJS/VBf0AyNT4vVc7H17Q6KjeNp +wEARqsIa7UalHx9MnxrQ/LSTTxiC8qmDkIFuQtw8iQMN0SoL5S0eCZNRD31awgaY +y1PvY8JMN549ugIUjOXnown/OxharLW1evWUraU5rArq3JfeFpPXl4K/u10T5SCL +iJRoxFilGPMFE3hvnmbi5rEy8wRUn7TpLb4I4s/CB/lT2qZTPqvQHwxKCnMm9BKF +NHb4rLL5dCvUi5NJ6fQ/exOoGdOVSfT7jqFeq2TtNunERSz9vpriweliB6iIe1Al +Thj8aEs1GqA764rLVGA+vUe18NhjJm9EemrdIzjSQFy/NdbN/DMaHqEzJogWloAI +izQWYnCS19TJ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICvTCCAkOgAwIBAgIQCIY7E/bFvFN2lK9Kckb0dTAKBggqhkjOPQQDAzCBnjEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTcwNQYDVQQDDC5BbWF6 +b24gUkRTIFByZXZpZXcgdXMtZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYD +VQQHDAdTZWF0dGxlMCAXDTIxMDUxODIxMDUxMFoYDzIxMjEwNTE4MjIwNTEwWjCB +njELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTcwNQYDVQQDDC5B +bWF6b24gUkRTIFByZXZpZXcgdXMtZWFzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEMI0hzf1JCEOI +Eue4+DmcNnSs2i2UaJxHMrNGGfU7b42a7vwP53F7045ffHPBGP4jb9q02/bStZzd +VHqfcgqkSRI7beBKjD2mfz82hF/wJSITTgCLs+NRpS6zKMFOFHUNo0IwQDAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBS8uF/6hk5mPLH4qaWv9NVZaMmyTjAOBgNV +HQ8BAf8EBAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIxAO7Pu9wzLyM0X7Q08uLIL+vL +qaxe3UFuzFTWjM16MLJHbzLf1i9IDFKz+Q4hXCSiJwIwClMBsqT49BPUxVsJnjGr +EbyEk6aOOVfY1p2yQL649zh3M4h8okLnwf+bYIb1YpeU +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQY+JhwFEQTe36qyRlUlF8ozANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE5MjQxNloYDzIwNjEwNTE5MjAyNDE2WjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnIye77j6ev40 +8wRPyN2OdKFSUfI9jB20Or2RLO+RDoL43+USXdrze0Wv4HMRLqaen9BcmCfaKMp0 +E4SFo47bXK/O17r6G8eyq1sqnHE+v288mWtYH9lAlSamNFRF6YwA7zncmE/iKL8J +0vePHMHP/B6svw8LULZCk+nZk3tgxQn2+r0B4FOz+RmpkoVddfqqUPMbKUxhM2wf +fO7F6bJaUXDNMBPhCn/3ayKCjYr49ErmnpYV2ZVs1i34S+LFq39J7kyv6zAgbHv9 ++/MtRMoRB1CjpqW0jIOZkHBdYcd1o9p1zFn591Do1wPkmMsWdjIYj+6e7UXcHvOB +2+ScIRAcnwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGtq2W +YSyMMxpdQ3IZvcGE+nyZqTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAEgoP3ixJsKSD5FN8dQ01RNHERl/IFbA7TRXfwC+L1yFocKnQh4Mp/msPRSV ++OeHIvemPW/wtZDJzLTOFJ6eTolGekHK1GRTQ6ZqsWiU2fmiOP8ks4oSpI+tQ9Lw +VrfZqTiEcS5wEIqyfUAZZfKDo7W1xp+dQWzfczSBuZJZwI5iaha7+ILM0r8Ckden +TVTapc5pLSoO15v0ziRuQ2bT3V3nwu/U0MRK44z+VWOJdSiKxdnOYDs8hFNnKhfe +klbTZF7kW7WbiNYB43OaAQBJ6BALZsIskEaqfeZT8FD71uN928TcEQyBDXdZpRN+ +iGQZDGhht0r0URGMDSs9waJtTfA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtDCCAjqgAwIBAgIRANtElQUxBpw5GPpq+Tqajs4wCgYIKoZIzj0EAwMwgZkx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h +em9uIFJEUyBteC1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjQwOTMwMTYxNDAwWhgPMjEyNDA5MzAxNzEzNTlaMIGZMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv +biBSRFMgbXgtY2VudHJhbC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEB5xlumRhIemjIWjxWQHqDYG7 +I24vWwUWZjcxcqpDHv6ThnJRsN4TCbWiSfjCOTeW9ZMQiMm6xlxug1nXkDGAyuHL +ze0PqtP0rUPi25wmp3F6L4vq96az41xtGa4wc/xSo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBSEfpD78dOGnSCKk9cYr2mAc4j9cTAOBgNVHQ8BAf8EBAMC +AYYwCgYIKoZIzj0EAwMDaAAwZQIxANNu+hmW2xOajUvFzdVV4fO0Slj6BRklQfjO +hSF/1NvfZK9pF/QDIyknmK5KS+bY+QIwZCxY71nDC4n4Jw1l0gF+krPg3gNS2KKF +FdrlaY1qdiyvyG6t6tp84q9XmGJ1rOhJ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIQEbIZbn8kcnd/sTnZkdoDkzANBgkqhkiG9w0BAQsFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC01IFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yNDA1MTUyMTUwMzdaGA8yMDY0MDUxNTIyNTAzN1ow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNSBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCK +c15oRFUw/MiZQ/qkfOlrcc/PC9TdxGjUqdZyQGqBWrFauIbsK7U0qTeTibt7t7cL +hBWmqb3eefU8e+JZwJ20/cFfWINEjp9xLKV5pzfcRH+BJF3Sa4iLeLSi8CEp5qvf +k70ADs2kye17q29G01NfCG9T2oMEEJQof1nKcfwjayjx7uyBPHtR0a2SC88QlSl9 +9a009S0pUoISV3Zu/U+B6vUlBnGuIt+EsEFH0r19w/VRSO5mg9ylxh0/X5HXeBK5 +UxpNpXI9rPNd/AMTrv7FTyWsqkeSRS2lyT/8wyatApcCdPLyJDx7wZLY8/wARz7p +zi/uEKlhrrSDhDq2I7JFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFAHKs1jyaNzThRo5XHN/dNJDtVNHMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG +9w0BAQsFAAOCAQEAVAdR60a4czTpyu3JHj6oNVQUTt1D1jD/y1fZcc5a77fa2Qc6 +ZZEVBadpXAwkUQDbVRu/h6OrPhWKbQNLlTS1xzGuGeVbXSczvj37UB11WQfFN3M9 +Dpe5LTL0MCPO+elHzXrBhjhi9euCHXHDdvv4AZl7tfWuOrBdeBThXIehKniJmAjt +vq2mIHHThw2Wr+E65WerOVU+jepsG//1EkgrKfcGoS646jQXXKabW3cn0ymEV1/M +DhFbV05Jfvu969qcA3+TH1FaN/lAbwuSLFZ4HLFFjq7RVl/X//lyXl1q/coUdQXC +awfL88gOd/cYz0n5xm/S+gJUtOcz/dR6kV36NQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/jCCA+agAwIBAgIQXY/dmS+72lZPranO2JM9jjANBgkqhkiG9w0BAQwFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIGFwLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTI1MjEzNDUxWhgPMjEyMTA1MjUyMjM0NTFaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgYXAtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMyW9kBJjD/hx8e8 +b5E1sF42bp8TXsz1htSYE3Tl3T1Aq379DfEhB+xa/ASDZxt7/vwa81BkNo4M6HYq +okYIXeE7cu5SnSgjWXqcERhgPevtAwgmhdE3yREe8oz2DyOi2qKKZqah+1gpPaIQ +fK0uAqoeQlyHosye3KZZKkDHBatjBsQ5kf8lhuf7wVulEZVRHY2bP2X7N98PfbpL +QdH7mWXzDtJJ0LiwFwds47BrkgK1pkHx2p1mTo+HMkfX0P6Fq1atkVC2RHHtbB/X +iYyH7paaHBzviFrhr679zNqwXIOKlbf74w3mS11P76rFn9rS1BAH2Qm6eY5S/Fxe +HEKXm4kjPN63Zy0p3yE5EjPt54yPkvumOnT+RqDGJ2HCI9k8Ehcbve0ogfdRKNqQ +VHWYTy8V33ndQRHZlx/CuU1yN61TH4WSoMly1+q1ihTX9sApmlQ14B2pJi/9DnKW +cwECrPy1jAowC2UJ45RtC8UC05CbP9yrIy/7Noj8gQDiDOepm+6w1g6aNlWoiuQS +kyI6nzz1983GcnOHya73ga7otXo0Qfg9jPghlYiMomrgshlSLDHZG0Ib/3hb8cnR +1OcN9FpzNmVK2Ll1SmTMLrIhuCkyNYX9O/bOknbcf706XeESxGduSkHEjIw/k1+2 +Atteoq5dT6cwjnJ9hyhiueVlVkiDAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFLUI+DD7RJs+0nRnjcwIVWzzYSsFMA4GA1UdDwEB/wQEAwIBhjAN +BgkqhkiG9w0BAQwFAAOCAgEAb1mcCHv4qMQetLGTBH9IxsB2YUUhr5dda0D2BcHr +UtDbfd0VQs4tux6h/6iKwHPx0Ew8fuuYj99WknG0ffgJfNc5/fMspxR/pc1jpdyU +5zMQ+B9wi0lOZPO9uH7/pr+d2odcNEy8zAwqdv/ihsTwLmGP54is9fVbsgzNW1cm +HKAVL2t/Ope+3QnRiRilKCN1lzhav4HHdLlN401TcWRWKbEuxF/FgxSO2Hmx86pj +e726lweCTMmnq/cTsPOVY0WMjs0or3eHDVlyLgVeV5ldyN+ptg3Oit60T05SRa58 +AJPTaVKIcGQ/gKkKZConpu7GDofT67P/ox0YNY57LRbhsx9r5UY4ROgz7WMQ1yoS +Y+19xizm+mBm2PyjMUbfwZUyCxsdKMwVdOq5/UmTmdms+TR8+m1uBHPOTQ2vKR0s +Pd/THSzPuu+d3dbzRyDSLQbHFFneG760CUlD/ZmzFlQjJ89/HmAmz8IyENq+Sjhx +Jgzy+FjVZb8aRUoYLlnffpUpej1n87Ynlr1GrvC4GsRpNpOHlwuf6WD4W0qUTsC/ +C9JO+fBzUj/aWlJzNcLEW6pte1SB+EdkR2sZvWH+F88TxemeDrV0jKJw5R89CDf8 +ZQNfkxJYjhns+YeV0moYjqQdc7tq4i04uggEQEtVzEhRLU5PE83nlh/K2NZZm8Kj +dIA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAPVSMfFitmM5PhmbaOFoGfUwDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyB1cy1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNTIyMzQ1N1oYDzIwNjEwNTI1MjMzNDU3WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIHVzLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDu9H7TBeGoDzMr +dxN6H8COntJX4IR6dbyhnj5qMD4xl/IWvp50lt0VpmMd+z2PNZzx8RazeGC5IniV +5nrLg0AKWRQ2A/lGGXbUrGXCSe09brMQCxWBSIYe1WZZ1iU1IJ/6Bp4D2YEHpXrW +bPkOq5x3YPcsoitgm1Xh8ygz6vb7PsvJvPbvRMnkDg5IqEThapPjmKb8ZJWyEFEE +QRrkCIRueB1EqQtJw0fvP4PKDlCJAKBEs/y049FoOqYpT3pRy0WKqPhWve+hScMd +6obq8kxTFy1IHACjHc51nrGII5Bt76/MpTWhnJIJrCnq1/Uc3Qs8IVeb+sLaFC8K +DI69Sw6bAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFE7PCopt +lyOgtXX0Y1lObBUxuKaCMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAFj+bX8gLmMNefr5jRJfHjrL3iuZCjf7YEZgn89pS4z8408mjj9z6Q5D1H7yS +jNETVV8QaJip1qyhh5gRzRaArgGAYvi2/r0zPsy+Tgf7v1KGL5Lh8NT8iCEGGXwF +g3Ir+Nl3e+9XUp0eyyzBIjHtjLBm6yy8rGk9p6OtFDQnKF5OxwbAgip42CD75r/q +p421maEDDvvRFR4D+99JZxgAYDBGqRRceUoe16qDzbMvlz0A9paCZFclxeftAxv6 +QlR5rItMz/XdzpBJUpYhdzM0gCzAzdQuVO5tjJxmXhkSMcDP+8Q+Uv6FA9k2VpUV +E/O5jgpqUJJ2Hc/5rs9VkAPXeA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrzCCAjWgAwIBAgIQW0yuFCle3uj4vWiGU0SaGzAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGFmLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTE5MTkzNTE2WhgPMjEyMTA1MTkyMDM1MTZaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgYWYtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABDPiKNZSaXs3Un/J/v+LTsFDANHpi7en +oL2qh0u0DoqNzEBTbBjvO23bLN3k599zh6CY3HKW0r2k1yaIdbWqt4upMCRCcUFi +I4iedAmubgzh56wJdoMZztjXZRwDthTkJKNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUWbYkcrvVSnAWPR5PJhIzppcAnZIwDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2gAMGUCMCESGqpat93CjrSEjE7z+Hbvz0psZTHwqaxuiH64GKUm +mYynIiwpKHyBrzjKBmeDoQIxANGrjIo6/b8Jl6sdIZQI18V0pAyLfLiZjlHVOnhM +MOTVgr82ZuPoEHTX78MxeMnYlw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRAIbsx8XOl0sgTNiCN4O+18QwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTI1MjE1NDU4WhgPMjA2MTA1MjUyMjU0NTha +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +tROxwXWCgn5R9gI/2Ivjzaxc0g95ysBjoJsnhPdJEHQb7w3y2kWrVWU3Y9fOitgb +CEsnEC3PrhRnzNVW0fPsK6kbvOeCmjvY30rdbxbc8h+bjXfGmIOgAkmoULEr6Hc7 +G1Q/+tvv4lEwIs7bEaf+abSZxRJbZ0MBxhbHn7UHHDiMZYvzK+SV1MGCxx7JVhrm +xWu3GC1zZCsGDhB9YqY9eR6PmjbqA5wy8vqbC57dZZa1QVtWIQn3JaRXn+faIzHx +nLMN5CEWihsdmHBXhnRboXprE/OS4MFv1UrQF/XM/h5RBeCywpHePpC+Oe1T3LNC +iP8KzRFrjC1MX/WXJnmOVQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBS33XbXAUMs1znyZo4B0+B3D68WFTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBADuadd2EmlpueY2VlrIIPC30QkoA1EOSoCmZgN6124apkoY1 +HiV4r+QNPljN4WP8gmcARnNkS7ZeR4fvWi8xPh5AxQCpiaBMw4gcbTMCuKDV68Pw +P2dZCTMspvR3CDfM35oXCufdtFnxyU6PAyINUqF/wyTHguO3owRFPz64+sk3r2pT +WHmJjG9E7V+KOh0s6REgD17Gqn6C5ijLchSrPUHB0wOIkeLJZndHxN/76h7+zhMt +fFeNxPWHY2MfpcaLjz4UREzZPSB2U9k+y3pW1omCIcl6MQU9itGx/LpQE+H3ZeX2 +M2bdYd5L+ow+bdbGtsVKOuN+R9Dm17YpswF+vyQ= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAKlQ+3JX9yHXyjP/Ja6kZhkwDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBhcC1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MTkxNzQ1MjBaGA8yMTIxMDUxOTE4NDUyMFowgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBhcC1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAKtahBrpUjQ6 +H2mni05BAKU6Z5USPZeSKmBBJN3YgD17rJ93ikJxSgzJ+CupGy5rvYQ0xznJyiV0 +91QeQN4P+G2MjGQR0RGeUuZcfcZitJro7iAg3UBvw8WIGkcDUg+MGVpRv/B7ry88 +7E4OxKb8CPNoa+a9j6ABjOaaxaI22Bb7j3OJ+JyMICs6CU2bgkJaj3VUV9FCNUOc +h9PxD4jzT9yyGYm/sK9BAT1WOTPG8XQUkpcFqy/IerZDfiQkf1koiSd4s5VhBkUn +aQHOdri/stldT7a+HJFVyz2AXDGPDj+UBMOuLq0K6GAT6ThpkXCb2RIf4mdTy7ox +N5BaJ+ih+Ro3ZwPkok60egnt/RN98jgbm+WstgjJWuLqSNInnMUgkuqjyBWwePqX +Kib+wdpyx/LOzhKPEFpeMIvHQ3A0sjlulIjnh+j+itezD+dp0UNxMERlW4Bn/IlS +sYQVNfYutWkRPRLErXOZXtlxxkI98JWQtLjvGzQr+jywxTiw644FSLWdhKa6DtfU +2JWBHqQPJicMElfZpmfaHZjtXuCZNdZQXWg7onZYohe281ZrdFPOqC4rUq7gYamL +T+ZB+2P+YCPOLJ60bj/XSvcB7mesAdg8P0DNddPhHUFWx2dFqOs1HxIVB4FZVA9U +Ppbv4a484yxjTgG7zFZNqXHKTqze6rBBAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFCEAqjighncv/UnWzBjqu1Ka2Yb4MA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEAYyvumblckIXlohzi3QiShkZhqFzZultbFIu9 +GhA5CDar1IFMhJ9vJpO9nUK/camKs1VQRs8ZsBbXa0GFUM2p8y2cgUfLwFULAiC/ +sWETyW5lcX/xc4Pyf6dONhqFJt/ovVBxNZtcmMEWv/1D6Tf0nLeEb0P2i/pnSRR4 +Oq99LVFjossXtyvtaq06OSiUUZ1zLPvV6AQINg8dWeBOWRcQYhYcEcC2wQ06KShZ +0ahuu7ar5Gym3vuLK6nH+eQrkUievVomN/LpASrYhK32joQ5ypIJej3sICIgJUEP +UoeswJ+Z16f3ECoL1OSnq4A0riiLj1ZGmVHNhM6m/gotKaHNMxsK9zsbqmuU6IT/ +P6cR0S+vdigQG8ZNFf5vEyVNXhl8KcaJn6lMD/gMB2rY0qpaeTg4gPfU5wcg8S4Y +C9V//tw3hv0f2n+8kGNmqZrylOQDQWSSo8j8M2SRSXiwOHDoTASd1fyBEIqBAwzn +LvXVg8wQd1WlmM3b0Vrsbzltyh6y4SuKSkmgufYYvC07NknQO5vqvZcNoYbLNea3 +76NkFaMHUekSbwVejZgG5HGwbaYBgNdJEdpbWlA3X4yGRVxknQSUyt4dZRnw/HrX +k8x6/wvtw7wht0/DOqz1li7baSsMazqxx+jDdSr1h9xML416Q4loFCLgqQhil8Jq +Em4Hy3A= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBDCCAuygAwIBAgIQFn6AJ+uxaPDpNVx7174CpjANBgkqhkiG9w0BAQsFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjIxMjAyMjAxNDA4WhgPMjA2MjEyMDIyMTE0MDhaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgaWwtY2VudHJhbC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAL2xGTSJ +fXorki/dkkTqdLyv4U1neeFYEyUCPN/HJ7ZloNwhj8RBrHYhZ4qtvUAvN+rs8fUm +L0wmaL69ye61S+CSfDzNwBDGwOzUm/cc1NEJOHCm8XA0unBNBvpJTjsFk2LQ+rz8 +oU0lVV4mjnfGektrTDeADonO1adJvUTYmF6v1wMnykSkp8AnW9EG/6nwcAJuAJ7d +BfaLThm6lfxPdsBNG81DLKi2me2TLQ4yl+vgRKJi2fJWwA77NaDqQuD5upRIcQwt +5noJt2kFFmeiro98ZMMRaDTHAHhJfWkwkw5f2QNIww7T4r85IwbQCgJVRo4m4ZTC +W/1eiEccU2407mECAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +DNhVvGHzKXv0Yh6asK0apP9jJlUwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQCoEVTUY/rF9Zrlpb1Y1hptEguw0i2pCLakcmv3YNj6thsubbGeGx8Z +RjUA/gPKirpoae2HU1y64WEu7akwr6pdTRtXXjbe9NReT6OW/0xAwceSXCOiStqS +cMsWWTGg6BA3uHqad5clqITjDZr1baQ8X8en4SXRBxXyhJXbOkB60HOQeFR9CNeh +pJdrWLeNYXwU0Z59juqdVMGwvDAYdugWUhW2rhafVUXszfRA5c8Izc+E31kq90aY +LmxFXUHUfG0eQOmxmg+Z/nG7yLUdHIFA3id8MRh22hye3KvRdQ7ZVGFni0hG2vQQ +Q01AvD/rhzyjg0czzJKLK9U/RttwdMaV +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBTCCA+2gAwIBAgIRAJfKe4Zh4aWNt3bv6ZjQwogwDQYJKoZIhvcNAQEMBQAw +gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq +QW1hem9uIFJEUyBjYS1jZW50cmFsLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYD +VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMDg1M1oYDzIxMjEwNTIxMjMwODUzWjCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGNhLWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCpgUH6 +Crzd8cOw9prAh2rkQqAOx2vtuI7xX4tmBG4I/um28eBjyVmgwQ1fpq0Zg2nCKS54 +Nn0pCmT7f3h6Bvopxn0J45AzXEtajFqXf92NQ3iPth95GVfAJSD7gk2LWMhpmID9 +JGQyoGuDPg+hYyr292X6d0madzEktVVGO4mKTF989qEg+tY8+oN0U2fRTrqa2tZp +iYsmg350ynNopvntsJAfpCO/srwpsqHHLNFZ9jvhTU8uW90wgaKO9i31j/mHggCE ++CAOaJCM3g+L8DPl/2QKsb6UkBgaaIwKyRgKSj1IlgrK+OdCBCOgM9jjId4Tqo2j +ZIrrPBGl6fbn1+etZX+2/tf6tegz+yV0HHQRAcKCpaH8AXF44bny9andslBoNjGx +H6R/3ib4FhPrnBMElzZ5i4+eM/cuPC2huZMBXb/jKgRC/QN1Wm3/nah5FWq+yn+N +tiAF10Ga0BYzVhHDEwZzN7gn38bcY5yi/CjDUNpY0OzEe2+dpaBKPlXTaFfn9Nba +CBmXPRF0lLGGtPeTAgjcju+NEcVa82Ht1pqxyu2sDtbu3J5bxp4RKtj+ShwN8nut +Tkf5Ea9rSmHEY13fzgibZlQhXaiFSKA2ASUwgJP19Putm0XKlBCNSGCoECemewxL ++7Y8FszS4Uu4eaIwvXVqUEE2yf+4ex0hqQ1acQIDAQABo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBSeUnXIRxNbYsZLtKomIz4Y1nOZEzAOBgNVHQ8BAf8E +BAMCAYYwDQYJKoZIhvcNAQEMBQADggIBAIpRvxVS0dzoosBh/qw65ghPUGSbP2D4 +dm6oYCv5g/zJr4fR7NzEbHOXX5aOQnHbQL4M/7veuOCLNPOW1uXwywMg6gY+dbKe +YtPVA1as8G9sUyadeXyGh2uXGsziMFXyaESwiAXZyiYyKChS3+g26/7jwECFo5vC +XGhWpIO7Hp35Yglp8AnwnEAo/PnuXgyt2nvyTSrxlEYa0jus6GZEZd77pa82U1JH +qFhIgmKPWWdvELA3+ra1nKnvpWM/xX0pnMznMej5B3RT3Y+k61+kWghJE81Ix78T ++tG4jSotgbaL53BhtQWBD1yzbbilqsGE1/DXPXzHVf9yD73fwh2tGWSaVInKYinr +a4tcrB3KDN/PFq0/w5/21lpZjVFyu/eiPj6DmWDuHW73XnRwZpHo/2OFkei5R7cT +rn/YdDD6c1dYtSw5YNnS6hdCQ3sOiB/xbPRN9VWJa6se79uZ9NLz6RMOr73DNnb2 +bhIR9Gf7XAA5lYKqQk+A+stoKbIT0F65RnkxrXi/6vSiXfCh/bV6B41cf7MY/6YW +ehserSdjhQamv35rTFdM+foJwUKz1QN9n9KZhPxeRmwqPitAV79PloksOnX25ElN +SlyxdndIoA1wia1HRd26EFm2pqfZ2vtD2EjU3wD42CXX4H8fKVDna30nNFSYF0yn +jGKc3k6UNxpg +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/jCCA+agAwIBAgIQaRHaEqqacXN20e8zZJtmDDANBgkqhkiG9w0BAQwFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIHVzLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTI1MjIzODM1WhgPMjEyMTA1MjUyMzM4MzVaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgdXMtZWFzdC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAInfBCaHuvj6Rb5c +L5Wmn1jv2PHtEGMHm+7Z8dYosdwouG8VG2A+BCYCZfij9lIGszrTXkY4O7vnXgru +JUNdxh0Q3M83p4X+bg+gODUs3jf+Z3Oeq7nTOk/2UYvQLcxP4FEXILxDInbQFcIx +yen1ESHggGrjEodgn6nbKQNRfIhjhW+TKYaewfsVWH7EF2pfj+cjbJ6njjgZ0/M9 +VZifJFBgat6XUTOf3jwHwkCBh7T6rDpgy19A61laImJCQhdTnHKvzTpxcxiLRh69 +ZObypR7W04OAUmFS88V7IotlPmCL8xf7kwxG+gQfvx31+A9IDMsiTqJ1Cc4fYEKg +bL+Vo+2Ii4W2esCTGVYmHm73drznfeKwL+kmIC/Bq+DrZ+veTqKFYwSkpHRyJCEe +U4Zym6POqQ/4LBSKwDUhWLJIlq99bjKX+hNTJykB+Lbcx0ScOP4IAZQoxmDxGWxN +S+lQj+Cx2pwU3S/7+OxlRndZAX/FKgk7xSMkg88HykUZaZ/ozIiqJqSnGpgXCtED +oQ4OJw5ozAr+/wudOawaMwUWQl5asD8fuy/hl5S1nv9XxIc842QJOtJFxhyeMIXt +LVECVw/dPekhMjS3Zo3wwRgYbnKG7YXXT5WMxJEnHu8+cYpMiRClzq2BEP6/MtI2 +AZQQUFu2yFjRGL2OZA6IYjxnXYiRAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFADCcQCPX2HmkqQcmuHfiQ2jjqnrMA4GA1UdDwEB/wQEAwIBhjAN +BgkqhkiG9w0BAQwFAAOCAgEASXkGQ2eUmudIKPeOIF7RBryCoPmMOsqP0+1qxF8l +pGkwmrgNDGpmd9s0ArfIVBTc1jmpgB3oiRW9c6n2OmwBKL4UPuQ8O3KwSP0iD2sZ +KMXoMEyphCEzW1I2GRvYDugL3Z9MWrnHkoaoH2l8YyTYvszTvdgxBPpM2x4pSkp+ +76d4/eRpJ5mVuQ93nC+YG0wXCxSq63hX4kyZgPxgCdAA+qgFfKIGyNqUIqWgeyTP +n5OgKaboYk2141Rf2hGMD3/hsGm0rrJh7g3C0ZirPws3eeJfulvAOIy2IZzqHUSY +jkFzraz6LEH3IlArT3jUPvWKqvh2lJWnnp56aqxBR7qHH5voD49UpJWY1K0BjGnS +OHcurpp0Yt/BIs4VZeWdCZwI7JaSeDcPMaMDBvND3Ia5Fga0thgYQTG6dE+N5fgF +z+hRaujXO2nb0LmddVyvE8prYlWRMuYFv+Co8hcMdJ0lEZlfVNu0jbm9/GmwAZ+l +9umeYO9yz/uC7edC8XJBglMAKUmVK9wNtOckUWAcCfnPWYLbYa/PqtXBYcxrso5j +iaS/A7iEW51uteHBGrViCy1afGG+hiUWwFlesli+Rq4dNstX3h6h2baWABaAxEVJ +y1RnTQSz6mROT1VmZSgSVO37rgIyY0Hf0872ogcTS+FfvXgBxCxsNWEbiQ/XXva4 +0Ws= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBDCCAuygAwIBAgIQHK008RFz9XJfPKvg2ONLCjANBgkqhkiG9w0BAQsFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIG14LWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjQwOTMwMTYxMzUwWhgPMjA2NDA5MzAxNzEzNTBaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgbXgtY2VudHJhbC0xIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAIbH+1jM +u1AF7AnEnUU64au5HHnWlQZ5PJCRXmiG4q8NGooNG5A6Qd8lIboWnH6DKAfl3AL+ +ihAsLl9biZ/A/wvoNtW/EizjeYXmiuuCyY/Vo/yv78TW7YNspLYbhc35Lt5V+Hi1 +jnKgNf5IAPG4yIXuIYT+9pD6uCcHuoyzsI8f45oUX3o9PJKaBFWccsgIao1cmZhe +9ButRjAPPtEXyHhLoBgObn5D4G/RWyqXZg0xYTUHHAq6a61UPW8oQ1eqc0aqH+o8 +ZCekjwvgiqTRbUpcWm12KviLgZT1J+9noB7ZX5uY9N5FbOsuG92kkFwgS7cEqEXs +AUCm/EdPZyj6dyUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +e8h4FODoLdxY0tZkQY5RRjCEQsUwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQA7iEZTjpddHEaTQR3jbR98g8zO5aXKO9gfDmeD1Yuov50rV22gpYgU +Pz2TtFT2ftDmtqKZevzXCS0sE2a1SxP8jTUGTnRlGdMmeKGKfERneit26AQ6A3VW +qTB5Sv11oqTs2bgrM0Zd//DRWboqRuctjTl+AWCmFqTLTxYHefaDW6zHkVL/2jcP +V8MniRMzHKtDKURpl1R9lG3/HvC54XP63pJpmMRwE4b6LUuuwr6AIKKRMCLFIrGh +8g2OMeOMnXawUxQubTLqqT8yMrZwBD2W6yJnHtECJsBVj1rd3EZ/Cq64JbolFk7/ +fVTZlb5aHfj8sie/qmmiP+T45facauKY +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtDCCAjqgAwIBAgIRAMyaTlVLN0ndGp4ffwKAfoMwCgYIKoZIzj0EAwMwgZkx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h +em9uIFJEUyBtZS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjIwNTA3MDA0NDM3WhgPMjEyMjA1MDcwMTQ0MzdaMIGZMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv +biBSRFMgbWUtY2VudHJhbC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE19nCV1nsI6CohSor13+B25cr +zg+IHdi9Y3L7ziQnHWI6yjBazvnKD+oC71aRRlR8b5YXsYGUQxWzPLHN7EGPcSGv +bzA9SLG1KQYCJaQ0m9Eg/iGrwKWOgylbhVw0bCxoo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBS4KsknsJXM9+QPEkBdZxUPaLr11zAOBgNVHQ8BAf8EBAMC +AYYwCgYIKoZIzj0EAwMDaAAwZQIxAJaRgrYIEfXQMZQQDxMTYS0azpyWSseQooXo +L3nYq4OHGBgYyQ9gVjvRYWU85PXbfgIwdi82DtANQFkCu+j+BU0JBY/uRKPEeYzo +JG92igKIcXPqCoxIJ7lJbbzmuf73gQu5 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAJwCobx0Os8F7ihbJngxrR8wDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBtZS1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MjAxNzE1MzNaGA8yMTIxMDUyMDE4MTUzM1owgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBtZS1zb3V0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANukKwlm+ZaI +Y5MkWGbEVLApEyLmlrHLEg8PfiiEa9ts7jssQcin3bzEPdTqGr5jo91ONoZ3ccWq +xJgg1W3bLu5CAO2CqIOXTXHRyCO/u0Ch1FGgWB8xETPSi3UHt/Vn1ltdO6DYdbDU +mYgwzYrvLBdRCwxsb9o+BuYQHVFzUYonqk/y9ujz3gotzFq7r55UwDTA1ita3vb4 +eDKjIb4b1M4Wr81M23WHonpje+9qkkrAkdQcHrkgvSCV046xsq/6NctzwCUUNsgF +7Q1a8ut5qJEYpz5ta8vI1rqFqAMBqCbFjRYlmAoTTpFPOmzAVxV+YoqTrW5A16su +/2SXlMYfJ/n/ad/QfBNPPAAQMpyOr2RCL/YiL/PFZPs7NxYjnZHNWxMLSPgFyI+/ +t2klnn5jR76KJK2qimmaXedB90EtFsMRUU1e4NxH9gDuyrihKPJ3aVnZ35mSipvR +/1KB8t8gtFXp/VQaz2sg8+uxPMKB81O37fL4zz6Mg5K8+aq3ejBiyHucpFGnsnVB +3kQWeD36ONkybngmgWoyPceuSWm1hQ0Z7VRAQX+KlxxSaHmSaIk1XxZu9h9riQHx +fMuev6KXjRn/CjCoUTn+7eFrt0dT5GryQEIZP+nA0oq0LKxogigHNZlwAT4flrqb +JUfZJrqgoce5HjZSXl10APbtPjJi0fW9AgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFEfV+LztI29OVDRm0tqClP3NrmEWMA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEAvSNe+0wuk53KhWlRlRf2x/97H2Q76X3anzF0 +5fOSVm022ldALzXMzqOfdnoKIhAu2oVKiHHKs7mMas+T6TL+Mkphx0CYEVxFE3PG +061q3CqJU+wMm9W9xsB79oB2XG47r1fIEywZZ3GaRsatAbjcNOT8uBaATPQAfJFN +zjFe4XyN+rA4cFrYNvfHTeu5ftrYmvks7JlRaJgEGWsz+qXux7uvaEEVPqEumd2H +uYeaRNOZ2V23R009X5lbgBFx9tq5VDTnKhQiTQ2SeT0rc1W3Dz5ik6SbQQNP3nSR +0Ywy7r/sZ3fcDyfFiqnrVY4Ympfvb4YW2PZ6OsQJbzH6xjdnTG2HtzEU30ngxdp1 +WUEF4zt6rjJCp7QBUqXgdlHvJqYu6949qtWjEPiFN9uSsRV2i1YDjJqN52dLjAPn +AipJKo8x1PHTwUzuITqnB9BdP+5TlTl8biJfkEf/+08eWDTLlDHr2VrZLOLompTh +bS5OrhDmqA2Q+O+EWrTIhMflwwlCpR9QYM/Xwvlbad9H0FUHbJsCVNaru3wGOgWo +tt3dNSK9Lqnv/Ej9K9v6CRr36in4ylJKivhJ5B9E7ABHg7EpBJ1xi7O5eNDkNoJG ++pFyphJq3AkBR2U4ni2tUaTAtSW2tks7IaiDV+UMtqZyGabT5ISQfWLLtLHSWn2F +Tspdjbg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECTCCAvGgAwIBAgIRAJZFh4s9aZGzKaTMLrSb4acwDQYJKoZIhvcNAQELBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBCZXRhIHVzLWVhc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTE4MjEyODQxWhgPMjA2MTA1MTgyMjI4NDFa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgQmV0YSB1cy1lYXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +17i2yoU6diep+WrqxIn2CrDEO2NdJVwWTSckx4WMZlLpkQDoymSmkNHjq9ADIApD +A31Cx+843apL7wub8QkFZD0Tk7/ThdHWJOzcAM3ov98QBPQfOC1W5zYIIRP2F+vQ +TRETHQnLcW3rLv0NMk5oQvIKpJoC9ett6aeVrzu+4cU4DZVWYlJUoC/ljWzCluau +8blfW0Vwin6OB7s0HCG5/wijQWJBU5SrP/KAIPeQi1GqG5efbqAXDr/ple0Ipwyo +Xjjl73LenGUgqpANlC9EAT4i7FkJcllLPeK3NcOHjuUG0AccLv1lGsHAxZLgjk/x +z9ZcnVV9UFWZiyJTKxeKPwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1Ud +DgQWBBRWyMuZUo4gxCR3Luf9/bd2AqZ7CjAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZI +hvcNAQELBQADggEBAIqN2DlIKlvDFPO0QUZQVFbsi/tLdYM98/vvzBpttlTGVMyD +gJuQeHVz+MnhGIwoCGOlGU3OOUoIlLAut0+WG74qYczn43oA2gbMd7HoD7oL/IGg +njorBwJVcuuLv2G//SqM3nxGcLRtkRnQ+lvqPxMz9+0fKFUn6QcIDuF0QSfthLs2 +WSiGEPKO9c9RSXdRQ4pXA7c3hXng8P4A2ZmdciPne5Nu4I4qLDGZYRrRLRkNTrOi +TyS6r2HNGUfgF7eOSeKt3NWL+mNChcYj71/Vycf5edeczpUgfnWy9WbPrK1svKyl +aAs2xg+X6O8qB+Mnj2dNBzm+lZIS3sIlm+nO9sg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAPAlEk8VJPmEzVRRaWvTh2AwCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyB1cy1lYXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTI1MjI0MTU1WhgPMjEyMTA1MjUyMzQxNTVaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgdXMtZWFzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEx5xjrup8II4HOJw15NTnS3H5yMrQGlbj +EDA5MMGnE9DmHp5dACIxmPXPMe/99nO7wNdl7G71OYPCgEvWm0FhdvVUeTb3LVnV +BnaXt32Ek7/oxGk1T+Df03C+W0vmuJ+wo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBTGXmqBWN/1tkSea4pNw0oHrjk2UDAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIxAIqqZWCSrIkZ7zsv/FygtAusW6yvlL935YAWYPVXU30m +jkMFLM+/RJ9GMvnO8jHfCgIwB+whlkcItzE9CRQ6CsMo/d5cEHDUu/QW6jSIh9BR +OGh9pTYPVkUbBiKPA7lVVhre +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRAJGY9kZITwfSRaAS/bSBOw8wDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBzYS1lYXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE4MTEyMFoYDzIxMjEwNTE5MTkxMTIwWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIHNhLWVhc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDe2vlDp6Eo4WQi +Wi32YJOgdXHhxTFrLjB9SRy22DYoMaWfginJIwJcSR8yse8ZDQuoNhERB9LRggAE +eng23mhrfvtL1yQkMlZfBu4vG1nOb22XiPFzk7X2wqz/WigdYNBCqa1kK3jrLqPx +YUy7jk2oZle4GLVRTNGuMfcid6S2hs3UCdXfkJuM2z2wc3WUlvHoVNk37v2/jzR/ +hSCHZv5YHAtzL/kLb/e64QkqxKll5QmKhyI6d7vt6Lr1C0zb+DmwxUoJhseAS0hI +dRk5DklMb4Aqpj6KN0ss0HAYqYERGRIQM7KKA4+hxDMUkJmt8KqWKZkAlCZgflzl +m8NZ31o2cvBzf6g+VFHx+6iVrSkohVQydkCxx7NJ743iPKsh8BytSM4qU7xx4OnD +H2yNXcypu+D5bZnVZr4Pywq0w0WqbTM2bpYthG9IC4JeVUvZ2mDc01lqOlbMeyfT +og5BRPLDXdZK8lapo7se2teh64cIfXtCmM2lDSwm1wnH2iSK+AWZVIM3iE45WSGc +vZ+drHfVgjJJ5u1YrMCWNL5C2utFbyF9Obw9ZAwm61MSbPQL9JwznhNlCh7F2ANW +ZHWQPNcOAJqzE4uVcJB1ZeVl28ORYY1668lx+s9yYeMXk3QQdj4xmdnvoBFggqRB +ZR6Z0D7ZohADXe024RzEo1TukrQgKQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBT7Vs4Y5uG/9aXnYGNMEs6ycPUT3jAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBACN4Htp2PvGcQA0/sAS+qUVWWJoAXSsu8Pgc6Gar +7tKVlNJ/4W/a6pUV2Xo/Tz3msg4yiE8sMESp2k+USosD5n9Alai5s5qpWDQjrqrh +76AGyF2nzve4kIN19GArYhm4Mz/EKEG1QHYvBDGgXi3kNvL/a2Zbybp+3LevG+q7 +xtx4Sz9yIyMzuT/6Y7ijtiMZ9XbuxGf5wab8UtwT3Xq1UradJy0KCkzRJAz/Wy/X +HbTkEvKSaYKExH6sLo0jqdIjV/d2Io31gt4e0Ly1ER2wPyFa+pc/swu7HCzrN+iz +A2ZM4+KX9nBvFyfkHLix4rALg+WTYJa/dIsObXkdZ3z8qPf5A9PXlULiaa1mcP4+ +rokw74IyLEYooQ8iSOjxumXhnkTS69MAdGzXYE5gnHokABtGD+BB5qLhtLt4fqAp +8AyHpQWMyV42M9SJLzQ+iOz7kAgJOBOaVtJI3FV/iAg/eqWVm3yLuUTWDxSHrKuL +N19+pSjF6TNvUSFXwEa2LJkfDqIOCE32iOuy85QY//3NsgrSQF6UkSPa95eJrSGI +3hTRYYh3Up2GhBGl1KUy7/o0k3KRZTk4s38fylY8bZ3TakUOH5iIGoHyFVVcp361 +Pyy25SzFSmNalWoQd9wZVc/Cps2ldxhcttM+WLkFNzprd0VJa8qTz8vYtHP0ouDN +nWS0 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtDCCAjmgAwIBAgIQKKqVZvk6NsLET+uYv5myCzAKBggqhkjOPQQDAzCBmTEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6 +b24gUkRTIGlsLWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH +U2VhdHRsZTAgFw0yMjEyMDIyMDMyMjBaGA8yMTIyMTIwMjIxMzIyMFowgZkxCzAJ +BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw +EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u +IFJEUyBpbC1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASYwfvj8BmvLAP6UkNQ4X4dXBB/ +webBO7swW+8HnFN2DAu+Cn/lpcDpu+dys1JmkVX435lrCH3oZjol0kCDIM1lF4Cv ++78yoY1Jr/YMat22E4iz4AZd9q0NToS7+ZA0r2yjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFO/8Py16qPr7J2GWpvxlTMB+op7XMA4GA1UdDwEB/wQEAwIB +hjAKBggqhkjOPQQDAwNpADBmAjEAwk+rg788+u8JL6sdix7l57WTo8E/M+o3TO5x +uRuPdShrBFm4ArGR2PPs4zCQuKgqAjEAi0TA3PVqAxKpoz+Ps8/054p9WTgDfBFZ +i/lm2yTaPs0xjY6FNWoy7fsVw5oEKxOn +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAOY7gfcBZgR2tqfBzMbFQCUwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNCBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjIwNTI1MTY1NDU5WhgPMjEyMjA1MjUxNzU0NTla +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTQgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +lfxER43FuLRdL08bddF0YhbCP+XXKj1A/TFMXmd2My8XDei8rPXFYyyjMig9+xZw +uAsIxLwz8uiA26CKA8bCZKg5VG2kTeOJAfvBJaLv1CZefs3Z4Uf1Sjvm6MF2yqEj +GoORfyfL9HiZFTDuF/hcjWoKYCfMuG6M/wO8IbdICrX3n+BiYQJu/pFO660Mg3h/ +8YBBWYDbHoCiH/vkqqJugQ5BM3OI5nsElW51P1icEEqti4AZ7JmtSv9t7fIFBVyR +oaEyOgpp0sm193F/cDJQdssvjoOnaubsSYm1ep3awZAUyGN/X8MBrPY95d0hLhfH +Ehc5Icyg+hsosBljlAyksmt4hFQ9iBnWIz/ZTfGMck+6p3HVL9RDgvluez+rWv59 +8q7omUGsiPApy5PDdwI/Wt/KtC34/2sjslIJfvgifdAtkRPkhff1WEwER00ADrN9 +eGGInaCpJfb1Rq8cV2n00jxg7DcEd65VR3dmIRb0bL+jWK62ni/WdEyomAOMfmGj +aWf78S/4rasHllWJ+QwnaUYY3u6N8Cgio0/ep4i34FxMXqMV3V0/qXdfhyabi/LM +wCxNo1Dwt+s6OtPJbwO92JL+829QAxydfmaMTeHBsgMPkG7RwAekeuatKGHNsc2Z +x2Q4C2wVvOGAhcHwxfM8JfZs3nDSZJndtVVnFlUY0UECAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUpnG7mWazy6k97/tb5iduRB3RXgQwDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQCDLqq1Wwa9Tkuv7vxBnIeVvvFF +ecTn+P+wJxl9Qa2ortzqTHZsBDyJO62d04AgBwiDXkJ9a+bthgG0H1J7Xee8xqv1 +xyX2yKj24ygHjspLotKP4eDMdDi5TYq+gdkbPmm9Q69B1+W6e049JVGXvWG8/7kU +igxeuCYwtCCdUPRLf6D8y+1XMGgVv3/DSOHWvTg3MJ1wJ3n3+eve3rjGdRYWZeJu +k21HLSZYzVrCtUsh2YAeLnUbSxVuT2Xr4JehYe9zW5HEQ8Je/OUfnCy9vzoN/ITw +osAH+EBJQey7RxEDqMwCaRefH0yeHFcnOll0OXg/urnQmwbEYzQ1uutJaBPsjU0J +Qf06sMxI7GiB5nPE+CnI2sM6A9AW9kvwexGXpNJiLxF8dvPQthpOKGcYu6BFvRmt +6ctfXd9b7JJoVqMWuf5cCY6ihpk1e9JTlAqu4Eb/7JNyGiGCR40iSLvV28un9wiE +plrdYxwcNYq851BEu3r3AyYWw/UW1AKJ5tM+/Gtok+AphMC9ywT66o/Kfu44mOWm +L3nSLSWEcgfUVgrikpnyGbUnGtgCmHiMlUtNVexcE7OtCIZoVAlCGKNu7tyuJf10 +Qlk8oIIzfSIlcbHpOYoN79FkLoDNc2er4Gd+7w1oPQmdAB0jBJnA6t0OUBPKdDdE +Ufff2jrbfbzECn1ELg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCDCCA/CgAwIBAgIQIuO1A8LOnmc7zZ/vMm3TrDANBgkqhkiG9w0BAQwFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yMTA1MjQyMDQ2MThaGA8yMTIxMDUyNDIxNDYxOFow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDq +qRHKbG8ZK6/GkGm2cenznEF06yHwI1gD5sdsHjTgekDZ2Dl9RwtDmUH2zFuIQwGj +SeC7E2iKwrJRA5wYzL9/Vk8NOILEKQOP8OIKUHbc7q8rEtjs401KcU6pFBBEdO9G +CTiRhogq+8mhC13AM/UriZJbKhwgM2UaDOzAneGMhQAGjH8z83NsNcPxpYVE7tqM +sch5yLtIJLkJRusrmQQTeHUev16YNqyUa+LuFclFL0FzFCimkcxUhXlbfEKXbssS +yPzjiv8wokGyo7+gA0SueceMO2UjfGfute3HlXZDcNvBbkSY+ver41jPydyRD6Qq +oEkh0tyIbPoa3oU74kwipJtz6KBEA3u3iq61OUR0ENhR2NeP7CSKrC24SnQJZ/92 +qxusrbyV/0w+U4m62ug/o4hWNK1lUcc2AqiBOvCSJ7qpdteTFxcEIzDwYfERDx6a +d9+3IPvzMb0ZCxBIIUFMxLTF7yAxI9s6KZBBXSZ6tDcCCYIgEysEPRWMRAcG+ye/ +fZVn9Vnzsj4/2wchC2eQrYpb1QvG4eMXA4M5tFHKi+/8cOPiUzJRgwS222J8YuDj +yEBval874OzXk8H8Mj0JXJ/jH66WuxcBbh5K7Rp5oJn7yju9yqX6qubY8gVeMZ1i +u4oXCopefDqa35JplQNUXbWwSebi0qJ4EK0V8F9Q+QIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBT4ysqCxaPe7y+g1KUIAenqu8PAgzAOBgNVHQ8B +Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBALU8WN35KAjPZEX65tobtCDQFkIO +uJjv0alD7qLB0i9eY80C+kD87HKqdMDJv50a5fZdqOta8BrHutgFtDm+xo5F/1M3 +u5/Vva5lV4xy5DqPajcF4Mw52czYBmeiLRTnyPJsU93EQIC2Bp4Egvb6LI4cMOgm +4pY2hL8DojOC5PXt4B1/7c1DNcJX3CMzHDm4SMwiv2MAxSuC/cbHXcWMk+qXdrVx ++ayLUSh8acaAOy3KLs1MVExJ6j9iFIGsDVsO4vr4ZNsYQiyHjp+L8ops6YVBO5AT +k/pI+axHIVsO5qiD4cFWvkGqmZ0gsVtgGUchZaacboyFsVmo6QPrl28l6LwxkIEv +GGJYvIBW8sfqtGRspjfX5TlNy5IgW/VOwGBdHHsvg/xpRo31PR3HOFw7uPBi7cAr +FiZRLJut7af98EB2UvovZnOh7uIEGPeecQWeOTQfJeWet2FqTzFYd0NUMgqPuJx1 +vLKferP+ajAZLJvVnW1J7Vccx/pm0rMiUJEf0LRb/6XFxx7T2RGjJTi0EzXODTYI +gnLfBBjnolQqw+emf4pJ4pAtly0Gq1KoxTG2QN+wTd4lsCMjnelklFDjejwnl7Uy +vtxzRBAu/hi/AqDkDFf94m6j+edIrjbi9/JDFtQ9EDlyeqPgw0qwi2fwtJyMD45V +fejbXelUSJSzDIdY +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRALKta9z9tKpZhJN2aJJe8ekwDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBhcC1lYXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTI1MDIwMTAwMTAyNloYDzIxMjUwMjAxMDExMDI2WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGFwLWVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCg3M+MSjABBW+N +IIlr+XMnQoMYy40snt+HM7GFCSTC/NvYxbaMa5DuEy2gPXvtioO0bQrc0tw4VsHv +seudmrO0I2OBzEqvB6y0c7DWcAmDVQyccoth35ueXpxowhl6JqHPyKTB3TXXU30F +zku4HujjEoOzveIa8kfRnwkNySMXlKic6aBPcefoxjECrdlmJJHR6k/kFzAerWht +kPUmgMCjYH4gu+JLf8caEvPwmGrzWcUFEzcaF880O2bP+4dpcklfU6Vu5/8DzJyU +BVpBLaMoD3yvee4No5YSa0FvAGFUy50TWC2ycMDCcn7R0NCHBgQmwlalwEor8rr/ +ntnRhor/do98VZlTJmTS4WmYH3BZHVJar2kBDbb8mtxXrZzaXn92r20QvehD1QEA +8OFllftP7UVcLCWUL0CTsW6jzciTfSgYJNkWN/RCXZFaaGRZp+kHJ9m2eWAJqICH +oug9KFDgBCW68GJiZM+Xs86Vt/sNfu1u9JMAgeDvSeMpaHJRw3EJuS7fP8x7Tj8u +RUY+TbLnsSFYDzPMiup0CZjS1aqQn3jQMp8AWlP59mUbGq5OKNZ/HTDGKNFcvo9X +hkxdE305j+K1lcfNmaS7ACI8PyLgkIS8KAEK+H5ib4Z/+lF5Z8hvNxK//DmlXlWb +YfmQVckZfM4R8ny8DiQQRfAZ1LszwQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBT5hL3+jgVmIfysNjG07vfdRLyP0zAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBAHjQevQPydKkUu2mT2wmwi9ukD07gt6r1vdTHqjs +qbfqnH/BMGkaLsu5ECgcTyesueVVYQxDsRSPQr/EQh/knx5qAbJtV0IwiwXHFcat +JZ4lCh8Acuh1Lk2GbDzkJjkNh4QuriN5RCF6a8wqcjceuulKAo/oLxfI563M7bBQ +qs+NV5wqyeHsjVhK8xdgnGqxAyDQVOwVtaiZYmdGF0GwRAibYNs7JsPa1ZdDnPhI +rpAzOSE5W1gHSLC/NcQe2pHmgjNcr8KfxjVC6WgZ1IqiVNxGVjNBooT0lk4oi1w/ +TrWjTC3EhiwoU6ta/o2qBYQ+YVl9uleeIAhfqV+r4xgKfWWVvldA/VAyZBZzUwOF +JuD0TeiEjF6jllV49PxkI+P9oxa+JzUErrD0oQVEljkCCY7liGc2hWVUCWi7m+9D +BNunnlSwW9YOfnj1YX4tMD/62DJKH49wNh+sQb4PR9LKk5xwJQXBcPxMqgm+9BzY +Jb63ZG3l9Y4sQSnDUPCc64I7S7rhxpuaSypS6IB343F6UQTKTaiKLFkuhs3p6kd3 +rOzKVzieh3ejrpolg/8KZXBSahp2PBkFtFBIA7r5iy19LT7qhmK1KrZUuC/8CPrC +HtnCzGNHi9Xgho/LIvFkGtDYyKEQlj/mNoWOjqqsphEYZOsG1qyIhY+9HG3XTLQE +7BP7 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAN7Y9G9i4I+ZaslPobE7VL4wDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1ub3J0aGVhc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjEwNTIwMTYzMzIzWhgPMjEyMTA1MjAxNzMzMjNa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtbm9ydGhlYXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +4BEPCiIfiK66Q/qa8k+eqf1Q3qsa6Xuu/fPkpuStXVBShhtXd3eqrM0iT4Xxs420 +Va0vSB3oZ7l86P9zYfa60n6PzRxdYFckYX330aI7L/oFIdaodB/C9szvROI0oLG+ +6RwmIF2zcprH0cTby8MiM7G3v9ykpq27g4WhDC1if2j8giOQL3oHpUaByekZNIHF +dIllsI3RkXmR3xmmxoOxJM1B9MZi7e1CvuVtTGOnSGpNCQiqofehTGwxCN2wFSK8 +xysaWlw48G0VzZs7cbxoXMH9QbMpb4tpk0d+T8JfAPu6uWO9UwCLWWydf0CkmA/+ +D50/xd1t33X9P4FEaPSg5lYbHXzSLWn7oLbrN2UqMLaQrkoEBg/VGvzmfN0mbflw ++T87bJ/VEOVNlG+gepyCTf89qIQVWOjuYMox4sK0PjzZGsYEuYiq1+OUT3vk/e5K +ag1fCcq2Isy4/iwB2xcXrsQ6ljwdk1fc+EmOnjGKrhuOHJY3S+RFv4ToQBsVyYhC +XGaC3EkqIX0xaCpDimxYhFjWhpDXAjG/zJ+hRLDAMCMhl/LPGRk/D1kzSbPmdjpl +lEMK5695PeBvEBTQdBQdOiYgOU3vWU6tzwwHfiM2/wgvess/q0FDAHfJhppbgbb9 +3vgsIUcsvoC5o29JvMsUxsDRvsAfEmMSDGkJoA/X6GECAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUgEWm1mZCbGD6ytbwk2UU1aLaOUUwDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQBb4+ABTGBGwxK1U/q4g8JDqTQM +1Wh8Oz8yAk4XtPJMAmCctxbd81cRnSnePWw/hxViLVtkZ/GsemvXfqAQyOn1coN7 +QeYSw+ZOlu0j2jEJVynmgsR7nIRqE7QkCyZAU+d2FTJUfmee+IiBiGyFGgxz9n7A +JhBZ/eahBbiuoOik/APW2JWLh0xp0W0GznfJ8lAlaQTyDa8iDXmVtbJg9P9qzkvl +FgPXQttzEOyooF8Pb2LCZO4kUz+1sbU7tHdr2YE+SXxt6D3SBv+Yf0FlvyWLiqVk +GDEOlPPTDSjAWgKnqST8UJ0RDcZK/v1ixs7ayqQJU0GUQm1I7LGTErWXHMnCuHKe +UKYuiSZwmTcJ06NgdhcCnGZgPq13ryMDqxPeltQc3n5eO7f1cL9ERYLDLOzm6A9P +oQ3MfcVOsbHgGHZWaPSeNrQRN9xefqBXH0ZPasgcH9WJdsLlEjVUXoultaHOKx3b +UCCb+d3EfqF6pRT488ippOL6bk7zNubwhRa/+y4wjZtwe3kAX78ACJVcjPobH9jZ +ErySads5zdQeaoee5wRKdp3TOfvuCe4bwLRdhOLCHWzEcXzY3g/6+ppLvNom8o+h +Bh5X26G6KSfr9tqhQ3O9IcbARjnuPbvtJnoPY0gz3EHHGPhy0RNW8i2gl3nUp0ah +PtjwbKW0hYAhIttT0Q== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtzCCAj2gAwIBAgIQQRBQTs6Y3H1DDbpHGta3lzAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDYxMTAwMTI0M1oYDzIxMjEwNjExMDExMjQzWjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEs0942Xj4m/gKA+WA6F5h +AHYuek9eGpzTRoLJddM4rEV1T3eSueytMVKOSlS3Ub9IhyQrH2D8EHsLYk9ktnGR +pATk0kCYTqFbB7onNo070lmMJmGT/Q7NgwC8cySChFxbo0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBQ20iKBKiNkcbIZRu0y1uoF1yJTEzAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIwYv0wTSrpQTaPaarfLN8Xcqrqu3hzl07n +FrESIoRw6Cx77ZscFi2/MV6AFyjCV/TlAjEAhpwJ3tpzPXpThRML8DMJYZ3YgMh3 +CMuLqhPpla3cL0PhybrD27hJWl29C4el6aMO +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrDCCAjOgAwIBAgIQGcztRyV40pyMKbNeSN+vXTAKBggqhkjOPQQDAzCBljEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6 +b24gUkRTIHVzLWVhc3QtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTAgFw0yMTA1MjEyMzE1NTZaGA8yMTIxMDUyMjAwMTU1NlowgZYxCzAJBgNV +BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD +VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE +UyB1cy1lYXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQfDcv+GGRESD9wT+I5YIPRsD3L+/jsiIis +Tr7t9RSbFl+gYpO7ZbDXvNbV5UGOC5lMJo/SnqFRTC6vL06NF7qOHfig3XO8QnQz +6T5uhhrhnX2RSY3/10d2kTyHq3ZZg3+jQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFLDyD3PRyNXpvKHPYYxjHXWOgfPnMA4GA1UdDwEB/wQEAwIBhjAKBggq +hkjOPQQDAwNnADBkAjB20HQp6YL7CqYD82KaLGzgw305aUKw2aMrdkBR29J183jY +6Ocj9+Wcif9xnRMS+7oCMAvrt03rbh4SU9BohpRUcQ2Pjkh7RoY0jDR4Xq4qzjNr +5UFr3BXpFvACxXF51BksGQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjWgAwIBAgIQeKbS5zvtqDvRtwr5H48cAjAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIG1lLXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTIwMTcxOTU1WhgPMjEyMTA1MjAxODE5NTVaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgbWUtc291dGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABEKjgUaAPmUlRMEQdBC7BScAGosJ1zRV +LDd38qTBjzgmwBfQJ5ZfGIvyEK5unB09MB4e/3qqK5I/L6Qn5Px/n5g4dq0c7MQZ +u7G9GBYm90U3WRJBf7lQrPStXaRnS4A/O6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUNKcAbGEIn03/vkwd8g6jNyiRdD4wDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2cAMGQCMHIeTrjenCSYuGC6txuBt/0ZwnM/ciO9kHGWVCoK8QLs +jGghb5/YSFGZbmQ6qpGlSAIwVOQgdFfTpEfe5i+Vs9frLJ4QKAfc27cTNYzRIM0I +E+AJgK4C4+DiyyMzOpiCfmvq +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCDCCA/CgAwIBAgIQSFkEUzu9FYgC5dW+5lnTgjANBgkqhkiG9w0BAQwFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC0zIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yMTA2MTEwMDA4MzZaGA8yMTIxMDYxMTAxMDgzNlow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDx +my5Qmd8zdwaI/KOKV9Xar9oNbhJP5ED0JCiigkuvCkg5qM36klszE8JhsUj40xpp +vQw9wkYW4y+C8twBpzKGBvakqMnoaVUV7lOCKx0RofrnNwkZCboTBB4X/GCZ3fIl +YTybS7Ehi1UuiaZspIT5A2jidoA8HiBPk+mTg1UUkoWS9h+MEAPa8L4DY6fGf4pO +J1Gk2cdePuNzzIrpm2yPto+I8MRROwZ3ha7ooyymOXKtz2c7jEHHJ314boCXAv9G +cdo27WiebewZkHHH7Zx9iTIVuuk2abyVSzvLVeGv7Nuy4lmSqa5clWYqWsGXxvZ2 +0fZC5Gd+BDUMW1eSpW7QDTk3top6x/coNoWuLSfXiC5ZrJkIKimSp9iguULgpK7G +abMMN4PR+O+vhcB8E879hcwmS2yd3IwcPTl3QXxufqeSV58/h2ibkqb/W4Bvggf6 +5JMHQPlPHOqMCVFIHP1IffIo+Of7clb30g9FD2j3F4qgV3OLwEDNg/zuO1DiAvH1 +L+OnmGHkfbtYz+AVApkAZrxMWwoYrwpauyBusvSzwRE24vLTd2i80ZDH422QBLXG +rN7Zas8rwIiBKacJLYtBYETw8mfsNt8gb72aIQX6cZOsphqp6hUtKaiMTVgGazl7 +tBXqbB+sIv3S9X6bM4cZJKkMJOXbnyCCLZFYv8TurwIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MB0GA1UdDgQWBBTOVtaS1b/lz6yJDvNk65vEastbQTAOBgNVHQ8B +Af8EBAMCAYYwDQYJKoZIhvcNAQEMBQADggIBABEONg+TmMZM/PrYGNAfB4S41zp1 +3CVjslZswh/pC4kgXSf8cPJiUOzMwUevuFQj7tCqxQtJEygJM2IFg4ViInIah2kh +xlRakEGGw2dEVlxZAmmLWxlL1s1lN1565t5kgVwM0GVfwYM2xEvUaby6KDVJIkD3 +aM6sFDBshvVA70qOggM6kU6mwTbivOROzfoIQDnVaT+LQjHqY/T+ok6IN0YXXCWl +Favai8RDjzLDFwXSRvgIK+1c49vlFFY4W9Efp7Z9tPSZU1TvWUcKdAtV8P2fPHAS +vAZ+g9JuNfeawhEibjXkwg6Z/yFUueQCQOs9TRXYogzp5CMMkfdNJF8byKYqHscs +UosIcETnHwqwban99u35sWcoDZPr6aBIrz7LGKTJrL8Nis8qHqnqQBXu/fsQEN8u +zJ2LBi8sievnzd0qI0kaWmg8GzZmYH1JCt1GXSqOFkI8FMy2bahP7TUQR1LBUKQ3 +hrOSqldkhN+cSAOnvbQcFzLr+iEYEk34+NhcMIFVE+51KJ1n6+zISOinr6mI3ckX +6p2tmiCD4Shk2Xx/VTY/KGvQWKFcQApWezBSvDNlGe0yV71LtLf3dr1pr4ofo7cE +rYucCJ40bfxEU/fmzYdBF32xP7AOD9U0FbOR3Mcthc6Z6w20WFC+zru8FGY08gPf +WT1QcNdw7ntUJP/w +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrzCCAjWgAwIBAgIQARky6+5PNFRkFVOp3Ob1CTAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjIwNTIzMTg0MTI4WhgPMjEyMjA1MjMxOTQxMjdaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgZXUtc291dGgtMiBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABNVGL5oF7cfIBxKyWd2PVK/S5yQfaJY3 +QFHWvEdt6951n9JhiiPrHzfVHsxZp1CBjILRMzjgRbYWmc8qRoLkgGE7htGdwudJ +Fa/WuKzO574Prv4iZXUnVGTboC7JdvKbh6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUgDeIIEKynwUbNXApdIPnmRWieZwwDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2gAMGUCMEOOJfucrST+FxuqJkMZyCM3gWGZaB+/w6+XUAJC6hFM +uSTY0F44/bERkA4XhH+YGAIxAIpJQBakCA1/mXjsTnQ+0El9ty+LODp8ibkn031c +8DKDS7pR9UK7ZYdR6zFg3ZCjQw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjOgAwIBAgIQJvkWUcYLbnxtuwnyjMmntDAKBggqhkjOPQQDAzCBljEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMS8wLQYDVQQDDCZBbWF6 +b24gUkRTIGV1LXdlc3QtMyBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTAgFw0yMTA1MjUyMjI2MTJaGA8yMTIxMDUyNTIzMjYxMlowgZYxCzAJBgNV +BAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMwEQYD +VQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1hem9uIFJE +UyBldS13ZXN0LTMgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0bGUw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAARENn8uHCyjn1dFax4OeXxvbV861qsXFD9G +DshumTmFzWWHN/69WN/AOsxy9XN5S7Cgad4gQgeYYYgZ5taw+tFo/jQvCLY//uR5 +uihcLuLJ78opvRPvD9kbWZ6oXfBtFkWjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYD +VR0OBBYEFKiK3LpoF+gDnqPldGSwChBPCYciMA4GA1UdDwEB/wQEAwIBhjAKBggq +hkjOPQQDAwNpADBmAjEA+7qfvRlnvF1Aosyp9HzxxCbN7VKu+QXXPhLEBWa5oeWW +UOcifunf/IVLC4/FGCsLAjEAte1AYp+iJyOHDB8UYkhBE/1sxnFaTiEPbvQBU0wZ +SuwWVLhu2wWDuSW+K7tTuL8p +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAKeDpqX5WFCGNo94M4v69sUwDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBldS13ZXN0LTMgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNTIyMTgzM1oYDzIwNjEwNTI1MjMxODMzWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LXdlc3QtMyBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcKOTEMTfzvs4H +WtJR8gI7GXN6xesulWtZPv21oT+fLGwJ+9Bv8ADCGDDrDxfeH/HxJmzG9hgVAzVn +4g97Bn7q07tGZM5pVi96/aNp11velZT7spOJKfJDZTlGns6DPdHmx48whpdO+dOb +6+eR0VwCIv+Vl1fWXgoACXYCoKjhxJs+R+fwY//0JJ1YG8yjZ+ghLCJmvlkOJmE1 +TCPUyIENaEONd6T+FHGLVYRRxC2cPO65Jc4yQjsXvvQypoGgx7FwD5voNJnFMdyY +754JGPOOe/SZdepN7Tz7UEq8kn7NQSbhmCsgA/Hkjkchz96qN/YJ+H/okiQUTNB0 +eG9ogiVFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFFjayw9Y +MjbxfF14XAhMM2VPl0PfMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAAtmx6d9+9CWlMoU0JCirtp4dSS41bBfb9Oor6GQ8WIr2LdfZLL6uES/ubJPE +1Sh5Vu/Zon5/MbqLMVrfniv3UpQIof37jKXsjZJFE1JVD/qQfRzG8AlBkYgHNEiS +VtD4lFxERmaCkY1tjKB4Dbd5hfhdrDy29618ZjbSP7NwAfnwb96jobCmMKgxVGiH +UqsLSiEBZ33b2hI7PJ6iTJnYBWGuiDnsWzKRmheA4nxwbmcQSfjbrNwa93w3caL2 +v/4u54Kcasvcu3yFsUwJygt8z43jsGAemNZsS7GWESxVVlW93MJRn6M+MMakkl9L +tWaXdHZ+KUV7LhfYLb0ajvb40w== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBDCCAuygAwIBAgIQJ5oxPEjefCsaESSwrxk68DANBgkqhkiG9w0BAQsFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGV1LWNlbnRyYWwtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjIwNjA2MjExNzA1WhgPMjA2MjA2MDYyMjE3MDVaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALTQt5eX +g+VP3BjO9VBkWJhE0GfLrU/QIk32I6WvrnejayTrlup9H1z4QWlXF7GNJrqScRMY +KhJHlcP05aPsx1lYco6pdFOf42ybXyWHHJdShj4A5glU81GTT+VrXGzHSarLmtua +eozkQgPpDsSlPt0RefyTyel7r3Cq+5K/4vyjCTcIqbfgaGwTU36ffjM1LaPCuE4O +nINMeD6YuImt2hU/mFl20FZ+IZQUIFZZU7pxGLqTRz/PWcH8tDDxnkYg7tNuXOeN +JbTpXrw7St50/E9ZQ0llGS+MxJD8jGRAa/oL4G/cwnV8P2OEPVVkgN9xDDQeieo0 +3xkzolkDkmeKOnUCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU +bwu8635iQGQMRanekesORM8Hkm4wDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEB +CwUAA4IBAQAgN6LE9mUgjsj6xGCX1afYE69fnmCjjb0rC6eEe1mb/QZNcyw4XBIW +6+zTXo4mjZ4ffoxb//R0/+vdTE7IvaLgfAZgFsLKJCtYDDstXZj8ujQnGR9Pig3R +W+LpNacvOOSJSawNQq0Xrlcu55AU4buyD5VjcICnfF1dqBMnGTnh27m/scd/ZMx/ +kapHZ/fMoK2mAgSX/NvUKF3UkhT85vSSM2BTtET33DzCPDQTZQYxFBa4rFRmFi4c +BLlmIReiCGyh3eJhuUUuYAbK6wLaRyPsyEcIOLMQmZe1+gAFm1+1/q5Ke9ugBmjf +PbTWjsi/lfZ5CdVAhc5lmZj/l5aKqwaS +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAKKPTYKln9L4NTx9dpZGUjowCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyBldS13ZXN0LTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTIxMjI1NTIxWhgPMjEyMTA1MjEyMzU1MjFaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgZXUtd2VzdC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE/owTReDvaRqdmbtTzXbyRmEpKCETNj6O +hZMKH0F8oU9Tmn8RU7kQQj6xUKEyjLPrFBN7c+26TvrVO1KmJAvbc8bVliiJZMbc +C0yV5PtJTalvlMZA1NnciZuhxaxrzlK1o0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBT4i5HaoHtrs7Mi8auLhMbKM1XevDAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIxAK9A+8/lFdX4XJKgfP+ZLy5ySXC2E0Spoy12Gv2GdUEZ +p1G7c1KbWVlyb1d6subzkQIwKyH0Naf/3usWfftkmq8SzagicKz5cGcEUaULq4tO +GzA/AMpr63IDBAqkZbMDTCmH +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrzCCAjWgAwIBAgIQTgIvwTDuNWQo0Oe1sOPQEzAKBggqhkjOPQQDAzCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTI0MjEwNjM4WhgPMjEyMTA1MjQyMjA2MzhaMIGXMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpvbiBS +RFMgZXUtbm9ydGgtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwHU2VhdHRs +ZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABJuzXLU8q6WwSKXBvx8BbdIi3mPhb7Xo +rNJBfuMW1XRj5BcKH1ZoGaDGw+BIIwyBJg8qNmCK8kqIb4cH8/Hbo3Y+xBJyoXq/ +cuk8aPrxiNoRsKWwiDHCsVxaK9L7GhHHAqNCMEAwDwYDVR0TAQH/BAUwAwEB/zAd +BgNVHQ4EFgQUYgcsdU4fm5xtuqLNppkfTHM2QMYwDgYDVR0PAQH/BAQDAgGGMAoG +CCqGSM49BAMDA2gAMGUCMQDz/Rm89+QJOWJecYAmYcBWCcETASyoK1kbr4vw7Hsg +7Ew3LpLeq4IRmTyuiTMl0gMCMAa0QSjfAnxBKGhAnYxcNJSntUyyMpaXzur43ec0 +3D8npJghwC4DuICtKEkQiI5cSg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAORIGqQXLTcbbYT2upIsSnQwDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBldS1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMjA1MjMxODM0MjJaGA8yMTIyMDUyMzE5MzQyMlowgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBldS1zb3V0aC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAPKukwsW2s/h +1k+Hf65pOP0knVBnOnMQyT1mopp2XHGdXznj9xS49S30jYoUnWccyXgD983A1bzu +w4fuJRHg4MFdz/NWTgXvy+zy0Roe83OPIJjUmXnnzwUHQcBa9vl6XUO65iQ3pbSi +fQfNDFXD8cvuXbkezeADoy+iFAlzhXTzV9MD44GTuo9Z3qAXNGHQCrgRSCL7uRYt +t1nfwboCbsVRnElopn2cTigyVXE62HzBUmAw1GTbAZeFAqCn5giBWYAfHwTUldRL +6eEa6atfsS2oPNus4ZENa1iQxXq7ft+pMdNt0qKXTCZiiCZjmLkY0V9kWwHTRRF8 +r+75oSL//3di43QnuSCgjwMRIeWNtMud5jf3eQzSBci+9njb6DrrSUbx7blP0srg +94/C/fYOp/0/EHH34w99Th14VVuGWgDgKahT9/COychLOubXUT6vD1As47S9KxTv +yYleVKwJnF9cVjepODN72fNlEf74BwzgSIhUmhksmZSeJBabrjSUj3pdyo/iRZN/ +CiYz9YPQ29eXHPQjBZVIUqWbOVfdwsx0/Xu5T1e7yyXByQ3/oDulahtcoKPAFQ3J +ee6NJK655MdS7pM9hJnU2Rzu3qZ/GkM6YK7xTlMXVouPUZov/VbiaCKbqYDs8Dg+ +UKdeNXAT6+BMleGQzly1X7vjhgeA8ugVAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFJdaPwpCf78UolFTEn6GO85/QwUIMA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEAWkxHIT3mers5YnZRSVjmpxCLivGj1jMB9VYC +iKqTAeIvD0940L0YaZgivQll5pue8UUcQ6M2uCdVVAsNJdmQ5XHIYiGOknYPtxzO +aO+bnZp7VIZw/vJ49hvH6RreA2bbxYMZO/ossYdcWsWbOKHFrRmAw0AhtK/my51g +obV7eQg+WmlE5Iqc75ycUsoZdc3NimkjBi7LQoNP1HMvlLHlF71UZhQDdq+/WdV7 +0zmg+epkki1LjgMmuPyb+xWuYkFKT1/faX+Xs62hIm5BY+aI4if4RuQ+J//0pOSs +UajrjTo+jLGB8A96jAe8HaFQenbwMjlaHRDAF0wvbkYrMr5a6EbneAB37V05QD0Y +Rh4L4RrSs9DX2hbSmS6iLDuPEjanHKzglF5ePEvnItbRvGGkynqDVlwF+Bqfnw8l +0i8Hr1f1/LP1c075UjkvsHlUnGgPbLqA0rDdcxF8Fdlv1BunUjX0pVlz10Ha5M6P +AdyWUOneOfaA5G7jjv7i9qg3r99JNs1/Lmyg/tV++gnWTAsSPFSSEte81kmPhlK3 +2UtAO47nOdTtk+q4VIRAwY1MaOR7wTFZPfer1mWs4RhKNu/odp8urEY87iIzbMWT +QYO/4I6BGj9rEWNGncvR5XTowwIthMCj2KWKM3Z/JxvjVFylSf+s+FFfO1bNIm6h +u3UBpZI= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtDCCAjmgAwIBAgIQenQbcP/Zbj9JxvZ+jXbRnTAKBggqhkjOPQQDAzCBmTEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTIwMAYDVQQDDClBbWF6 +b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIEVDQzM4NCBHMTEQMA4GA1UEBwwH +U2VhdHRsZTAgFw0yMTA1MjEyMjMzMjRaGA8yMTIxMDUyMTIzMzMyNFowgZkxCzAJ +BgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMuMRMw +EQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1hem9u +IFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATlBHiEM9LoEb1Hdnd5j2VpCDOU +5nGuFoBD8ROUCkFLFh5mHrHfPXwBc63heW9WrP3qnDEm+UZEUvW7ROvtWCTPZdLz +Z4XaqgAlSqeE2VfUyZOZzBSgUUJk7OlznXfkCMOjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFDT/ThjQZl42Nv/4Z/7JYaPNMly2MA4GA1UdDwEB/wQEAwIB +hjAKBggqhkjOPQQDAwNpADBmAjEAnZWmSgpEbmq+oiCa13l5aGmxSlfp9h12Orvw +Dq/W5cENJz891QD0ufOsic5oGq1JAjEAp5kSJj0MxJBTHQze1Aa9gG4sjHBxXn98 +4MP1VGsQuhfndNHQb4V0Au7OWnOeiobq +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/zCCAuegAwIBAgIRAMgnyikWz46xY6yRgiYwZ3swDQYJKoZIhvcNAQELBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMDE2NDkxMloYDzIwNjEwNTIwMTc0OTEyWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LXdlc3QtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCi8JYOc9cYSgZH +gYPxLk6Xcc7HqzamvsnjYU98Dcb98y6iDqS46Ra2Ne02MITtU5MDL+qjxb8WGDZV +RUA9ZS69tkTO3gldW8QdiSh3J6hVNJQW81F0M7ZWgV0gB3n76WCmfT4IWos0AXHM +5v7M/M4tqVmCPViQnZb2kdVlM3/Xc9GInfSMCgNfwHPTXl+PXX+xCdNBePaP/A5C +5S0oK3HiXaKGQAy3K7VnaQaYdiv32XUatlM4K2WS4AMKt+2cw3hTCjlmqKRHvYFQ +veWCXAuc+U5PQDJ9SuxB1buFJZhT4VP3JagOuZbh5NWpIbOTxlAJOb5pGEDuJTKi +1gQQQVEFAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFNXm+N87 +OFxK9Af/bjSxDCiulGUzMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsFAAOC +AQEAkqIbkgZ45spvrgRQ6n9VKzDLvNg+WciLtmVrqyohwwJbj4pYvWwnKQCkVc7c +hUOSBmlSBa5REAPbH5o8bdt00FPRrD6BdXLXhaECKgjsHe1WW08nsequRKD8xVmc +8bEX6sw/utBeBV3mB+3Zv7ejYAbDFM4vnRsWtO+XqgReOgrl+cwdA6SNQT9oW3e5 +rSQ+VaXgJtl9NhkiIysq9BeYigxqS/A13pHQp0COMwS8nz+kBPHhJTsajHCDc8F4 +HfLi6cgs9G0gaRhT8FCH66OdGSqn196sE7Y3bPFFFs/3U+vxvmQgoZC6jegQXAg5 +Prxd+VNXtNI/azitTysQPumH7A== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEBTCCAu2gAwIBAgIRAO8bekN7rUReuNPG8pSTKtEwDQYJKoZIhvcNAQELBQAw +gZoxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEzMDEGA1UEAwwq +QW1hem9uIFJEUyBldS1jZW50cmFsLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYD +VQQHDAdTZWF0dGxlMCAXDTIxMDUyMTIyMjM0N1oYDzIwNjEwNTIxMjMyMzQ3WjCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTIwNDggRzExEDAOBgNV +BAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCTTYds +Tray+Q9VA5j5jTh5TunHKFQzn68ZbOzdqaoi/Rq4ohfC0xdLrxCpfqn2TGDHN6Zi +2qGK1tWJZEd1H0trhzd9d1CtGK+3cjabUmz/TjSW/qBar7e9MA67/iJ74Gc+Ww43 +A0xPNIWcL4aLrHaLm7sHgAO2UCKsrBUpxErOAACERScVYwPAfu79xeFcX7DmcX+e +lIqY16pQAvK2RIzrekSYfLFxwFq2hnlgKHaVgZ3keKP+nmXcXmRSHQYUUr72oYNZ +HcNYl2+gxCc9ccPEHM7xncVEKmb5cWEWvVoaysgQ+osi5f5aQdzgC2X2g2daKbyA +XL/z5FM9GHpS5BJjAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYE +FBDAiJ7Py9/A9etNa/ebOnx5l5MGMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0B +AQsFAAOCAQEALMh/+81fFPdJV/RrJUeoUvFCGMp8iaANu97NpeJyKitNOv7RoeVP +WjivS0KcCqZaDBs+p6IZ0sLI5ZH098LDzzytcfZg0PsGqUAb8a0MiU/LfgDCI9Ee +jsOiwaFB8k0tfUJK32NPcIoQYApTMT2e26lPzYORSkfuntme2PTHUnuC7ikiQrZk +P+SZjWgRuMcp09JfRXyAYWIuix4Gy0eZ4rpRuaTK6mjAb1/LYoNK/iZ/gTeIqrNt +l70OWRsWW8jEmSyNTIubGK/gGGyfuZGSyqoRX6OKHESkP6SSulbIZHyJ5VZkgtXo +2XvyRyJ7w5pFyoofrL3Wv0UF8yt/GDszmg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRAMDk/F+rrhdn42SfE+ghPC8wDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBldS13ZXN0LTIgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMTIyNTEyMloYDzIxMjEwNTIxMjM1MTIyWjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LXdlc3QtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2twMALVg9vRVu +VNqsr6N8thmp3Dy8jEGTsm3GCQ+C5P2YcGlD/T/5icfWW84uF7Sx3ezcGlvsqFMf +Ukj9sQyqtz7qfFFugyy7pa/eH9f48kWFHLbQYm9GEgbYBIrWMp1cy3vyxuMCwQN4 +DCncqU+yNpy0CprQJEha3PzY+3yJOjDQtc3zr99lyECCFJTDUucxHzyQvX89eL74 +uh8la0lKH3v9wPpnEoftbrwmm5jHNFdzj7uXUHUJ41N7af7z7QUfghIRhlBDiKtx +5lYZemPCXajTc3ryDKUZC/b+B6ViXZmAeMdmQoPE0jwyEp/uaUcdp+FlUQwCfsBk +ayPFEApTWgPiku2isjdeTVmEgL8bJTDUZ6FYFR7ZHcYAsDzcwHgIu3GGEMVRS3Uf +ILmioiyly9vcK4Sa01ondARmsi/I0s7pWpKflaekyv5boJKD/xqwz9lGejmJHelf +8Od2TyqJScMpB7Q8c2ROxBwqwB72jMCEvYigB+Wnbb8RipliqNflIGx938FRCzKL +UQUBmNAznR/yRRL0wHf9UAE/8v9a09uZABeiznzOFAl/frHpgdAbC00LkFlnwwgX +g8YfEFlkp4fLx5B7LtoO6uVNFVimLxtwirpyKoj3G4M/kvSTux8bTw0heBCmWmKR +57MS6k7ODzbv+Kpeht2hqVZCNFMxoQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBRuMnDhJjoj7DcKALj+HbxEqj3r6jAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBALSnXfx72C3ldhBP5kY4Mo2DDaGQ8FGpTOOiD95d +0rf7I9LrsBGVqu/Nir+kqqP80PB70+Jy9fHFFigXwcPBX3MpKGxK8Cel7kVf8t1B +4YD6A6bqlzP+OUL0uGWfZpdpDxwMDI2Flt4NEldHgXWPjvN1VblEKs0+kPnKowyg +jhRMgBbD/y+8yg0fIcjXUDTAw/+INcp21gWaMukKQr/8HswqC1yoqW9in2ijQkpK +2RB9vcQ0/gXR0oJUbZQx0jn0OH8Agt7yfMAnJAdnHO4M3gjvlJLzIC5/4aGrRXZl +JoZKfJ2fZRnrFMi0nhAYDeInoS+Rwx+QzaBk6fX5VPyCj8foZ0nmqvuYoydzD8W5 +mMlycgxFqS+DUmO+liWllQC4/MnVBlHGB1Cu3wTj5kgOvNs/k+FW3GXGzD3+rpv0 +QTLuwSbMr+MbEThxrSZRSXTCQzKfehyC+WZejgLb+8ylLJUA10e62o7H9PvCrwj+ +ZDVmN7qj6amzvndCP98sZfX7CFZPLfcBd4wVIjHsFjSNEwWHOiFyLPPG7cdolGKA +lOFvonvo4A1uRc13/zFeP0Xi5n5OZ2go8aOOeGYdI2vB2sgH9R2IASH/jHmr0gvY +0dfBCcfXNgrS0toq0LX/y+5KkKOxh52vEYsJLdhqrveuZhQnsFEm/mFwjRXkyO7c +2jpC +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGADCCA+igAwIBAgIQYe0HgSuFFP9ivYM2vONTrTANBgkqhkiG9w0BAQwFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE4MzMyMVoYDzIxMjEwNTE5MTkzMzIxWjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuO7QPKfPMTo2 +POQWvzDLwi5f++X98hGjORI1zkN9kotCYH5pAzSBwBPoMNaIfedgmsIxGHj2fq5G +4oXagNhNuGP79Zl6uKW5H7S74W7aWM8C0s8zuxMOI4GZy5h2IfQk3m/3AzZEX5w8 +UtNPkzo2feDVOkerHT+j+vjXgAxZ4wHnuMDcRT+K4r9EXlAH6X9b/RO0JlfEwmNz +xlqqGxocq9qRC66N6W0HF2fNEAKP84n8H80xcZBOBthQORRi8HSmKcPdmrvwCuPz +M+L+j18q6RAVaA0ABbD0jMWcTf0UvjUfBStn5mvu/wGlLjmmRkZsppUTRukfwqXK +yltUsTq0tOIgCIpne5zA4v+MebbR5JBnsvd4gdh5BI01QH470yB7BkUefZ9bobOm +OseAAVXcYFJKe4DAA6uLDrqOfFSxV+CzVvEp3IhLRaik4G5MwI/h2c/jEYDqkg2J +HMflxc2gcSMdk7E5ByLz5f6QrFfSDFk02ZJTs4ssbbUEYohht9znPMQEaWVqATWE +3n0VspqZyoBNkH/agE5GiGZ/k/QyeqzMNj+c9kr43Upu8DpLrz8v2uAp5xNj3YVg +ihaeD6GW8+PQoEjZ3mrCmH7uGLmHxh7Am59LfEyNrDn+8Rq95WvkmbyHSVxZnBmo +h/6O3Jk+0/QhIXZ2hryMflPcYWeRGH0CAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQU2eFK7+R3x/me8roIBNxBrplkM6EwDgYDVR0PAQH/BAQDAgGG +MA0GCSqGSIb3DQEBDAUAA4ICAQB5gWFe5s7ObQFj1fTO9L6gYgtFhnwdmxU0q8Ke +HWCrdFmyXdC39qdAFOwM5/7fa9zKmiMrZvy9HNvCXEp4Z7z9mHhBmuqPZQx0qPgU +uLdP8wGRuWryzp3g2oqkX9t31Z0JnkbIdp7kfRT6ME4I4VQsaY5Y3mh+hIHOUvcy +p+98i3UuEIcwJnVAV9wTTzrWusZl9iaQ1nSYbmkX9bBssJ2GmtW+T+VS/1hJ/Q4f +AlE3dOQkLFoPPb3YRWBHr2n1LPIqMVwDNAuWavRA2dSfaLl+kzbn/dua7HTQU5D4 +b2Fu2vLhGirwRJe+V7zdef+tI7sngXqjgObyOeG5O2BY3s+um6D4fS0Th3QchMO7 +0+GwcIgSgcjIjlrt6/xJwJLE8cRkUUieYKq1C4McpZWTF30WnzOPUzRzLHkcNzNA +0A7sKMK6QoYWo5Rmo8zewUxUqzc9oQSrYADP7PEwGncLtFe+dlRFx+PA1a+lcIgo +1ZGfXigYtQ3VKkcknyYlJ+hN4eCMBHtD81xDy9iP2MLE41JhLnoB2rVEtewO5diF +7o95Mwl84VMkLhhHPeGKSKzEbBtYYBifHNct+Bst8dru8UumTltgfX6urH3DN+/8 +JF+5h3U8oR2LL5y76cyeb+GWDXXy9zoQe2QvTyTy88LwZq1JzujYi2k8QiLLhFIf +FEv9Bg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICsDCCAjagAwIBAgIRAMgApnfGYPpK/fD0dbN2U4YwCgYIKoZIzj0EAwMwgZcx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwnQW1h +em9uIFJEUyBldS1zb3V0aC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMCAXDTIxMDUxOTE4MzgxMVoYDzIxMjEwNTE5MTkzODExWjCBlzELMAkG +A1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4xEzAR +BgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6b24g +UkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1NlYXR0 +bGUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQfEWl6d4qSuIoECdZPp+39LaKsfsX7 +THs3/RrtT0+h/jl3bjZ7Qc68k16x+HGcHbaayHfqD0LPdzH/kKtNSfQKqemdxDQh +Z4pwkixJu8T1VpXZ5zzCvBXCl75UqgEFS92jQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFFPrSNtWS5JU+Tvi6ABV231XbjbEMA4GA1UdDwEB/wQEAwIBhjAK +BggqhkjOPQQDAwNoADBlAjEA+a7hF1IrNkBd2N/l7IQYAQw8chnRZDzh4wiGsZsC +6A83maaKFWUKIb3qZYXFSi02AjAbp3wxH3myAmF8WekDHhKcC2zDvyOiKLkg9Y6v +ZVmyMR043dscQbcsVoacOYv198c= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtDCCAjqgAwIBAgIRAPhVkIsQ51JFhD2kjFK5uAkwCgYIKoZIzj0EAwMwgZkx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEyMDAGA1UEAwwpQW1h +em9uIFJEUyBldS1jZW50cmFsLTIgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjIwNjA2MjEyOTE3WhgPMjEyMjA2MDYyMjI5MTdaMIGZMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMjAwBgNVBAMMKUFtYXpv +biBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEA5xnIEBtG5b2nmbj49UEwQza +yX0844fXjccYzZ8xCDUe9dS2XOUi0aZlGblgSe/3lwjg8fMcKXLObGGQfgIx1+5h +AIBjORis/dlyN5q/yH4U5sjS8tcR0GDGVHrsRUZCo0IwQDAPBgNVHRMBAf8EBTAD +AQH/MB0GA1UdDgQWBBRK+lSGutXf4DkTjR3WNfv4+KeNFTAOBgNVHQ8BAf8EBAMC +AYYwCgYIKoZIzj0EAwMDaAAwZQIxAJ4NxQ1Gerqr70ZrnUqc62Vl8NNqTzInamCG +Kce3FTsMWbS9qkgrjZkO9QqOcGIw/gIwSLrwUT+PKr9+H9eHyGvpq9/3AIYSnFkb +Cf3dyWPiLKoAtLFwjzB/CkJlsAS1c8dS +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/jCCA+agAwIBAgIQGZH12Q7x41qIh9vDu9ikTjANBgkqhkiG9w0BAQwFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIGV1LXdlc3QtMyBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTI1MjIyMjMzWhgPMjEyMTA1MjUyMzIyMzNaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgZXUtd2VzdC0zIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMqE47sHXWzdpuqj +JHb+6jM9tDbQLDFnYjDWpq4VpLPZhb7xPNh9gnYYTPKG4avG421EblAHqzy9D2pN +1z90yKbIfUb/Sy2MhQbmZomsObhONEra06fJ0Dydyjswf1iYRp2kwpx5AgkVoNo7 +3dlws73zFjD7ImKvUx2C7B75bhnw2pJWkFnGcswl8fZt9B5Yt95sFOKEz2MSJE91 +kZlHtya19OUxZ/cSGci4MlOySzqzbGwUqGxEIDlY8I39VMwXaYQ8uXUN4G780VcL +u46FeyRGxZGz2n3hMc805WAA1V5uir87vuirTvoSVREET97HVRGVVNJJ/FM6GXr1 +VKtptybbo81nefYJg9KBysxAa2Ao2x2ry/2ZxwhS6VZ6v1+90bpZA1BIYFEDXXn/ +dW07HSCFnYSlgPtSc+Muh15mdr94LspYeDqNIierK9i4tB6ep7llJAnq0BU91fM2 +JPeqyoTtc3m06QhLf68ccSxO4l8Hmq9kLSHO7UXgtdjfRVaffngopTNk8qK7bIb7 +LrgkqhiQw/PRCZjUdyXL153/fUcsj9nFNe25gM4vcFYwH6c5trd2tUl31NTi1MfG +Mgp3d2dqxQBIYANkEjtBDMy3SqQLIo9EymqmVP8xx2A/gCBgaxvMAsI6FSWRoC7+ +hqJ8XH4mFnXSHKtYMe6WPY+/XZgtAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8w +HQYDVR0OBBYEFIkXqTnllT/VJnI2NqipA4XV8rh1MA4GA1UdDwEB/wQEAwIBhjAN +BgkqhkiG9w0BAQwFAAOCAgEAKjSle8eenGeHgT8pltWCw/HzWyQruVKhfYIBfKJd +MhV4EnH5BK7LxBIvpXGsFUrb0ThzSw0fn0zoA9jBs3i/Sj6KyeZ9qUF6b8ycDXd+ +wHonmJiQ7nk7UuMefaYAfs06vosgl1rI7eBHC0itexIQmKh0aX+821l4GEgEoSMf +loMFTLXv2w36fPHHCsZ67ODldgcZbKNnpCTX0YrCwEYO3Pz/L398btiRcWGrewrK +jdxAAyietra8DRno1Zl87685tfqc6HsL9v8rVw58clAo9XAQvT+fmSOFw/PogRZ7 +OMHUat3gu/uQ1M5S64nkLLFsKu7jzudBuoNmcJysPlzIbqJ7vYc82OUGe9ucF3wi +3tbKQ983hdJiTExVRBLX/fYjPsGbG3JtPTv89eg2tjWHlPhCDMMxyRKl6isu2RTq +6VT489Z2zQrC33MYF8ZqO1NKjtyMAMIZwxVu4cGLkVsqFmEV2ScDHa5RadDyD3Ok +m+mqybhvEVm5tPgY6p0ILPMN3yvJsMSPSvuBXhO/X5ppNnpw9gnxpwbjQKNhkFaG +M5pkADZ14uRguOLM4VthSwUSEAr5VQYCFZhEwK+UOyJAGiB/nJz6IxL5XBNUXmRM +Hl8Xvz4riq48LMQbjcVQj0XvH941yPh+P8xOi00SGaQRaWp55Vyr4YKGbV0mEDz1 +r1o= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIF/zCCA+egAwIBAgIRAKwYju1QWxUZpn6D1gOtwgQwDQYJKoZIhvcNAQEMBQAw +gZcxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEwMC4GA1UEAwwn +QW1hem9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBSU0E0MDk2IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyMDE2NTM1NFoYDzIxMjEwNTIwMTc1MzU0WjCBlzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdBbWF6 +b24gUkRTIGV1LXdlc3QtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCKdBP1U4lqWWkc +Cb25/BKRTsvNVnISiKocva8GAzJyKfcGRa85gmgu41U+Hz6+39K+XkRfM0YS4BvQ +F1XxWT0bNyypuvwCvmYShSTjN1TY0ltncDddahTajE/4MdSOZb/c98u0yt03cH+G +hVwRyT50h0v/UEol50VfwcVAEZEgcQQYhf1IFUFlIvKpmDOqLuFakOnc7c9akK+i +ivST+JO1tgowbnNkn2iLlSSgUWgb1gjaOsNfysagv1RXdlyPw3EyfwkFifAQvF2P +Q0ayYZfYS640cccv7efM1MSVyFHR9PrrDsF/zr2S2sGPbeHr7R/HwLl+S5J/l9N9 +y0rk6IHAWV4dEkOvgpnuJKURwA48iu1Hhi9e4moNS6eqoK2KmY3VFpuiyWcA73nH +GSmyaH+YuMrF7Fnuu7GEHZL/o6+F5cL3mj2SJJhL7sz0ryf5Cs5R4yN9BIEj/f49 +wh84pM6nexoI0Q4wiSFCxWiBpjSmOK6h7z6+2utaB5p20XDZHhxAlmlx4vMuWtjh +XckgRFxc+ZpVMU3cAHUpVEoO49e/+qKEpPzp8Xg4cToKw2+AfTk3cmyyXQfGwXMQ +ZUHNZ3w9ILMWihGCM2aGUsLcGDRennvNmnmin/SENsOQ8Ku0/a3teEzwV9cmmdYz +5iYs1YtgPvKFobY6+T2RXXh+A5kprwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/ +MB0GA1UdDgQWBBSyUrsQVnKmA8z6/2Ech0rCvqpNmTAOBgNVHQ8BAf8EBAMCAYYw +DQYJKoZIhvcNAQEMBQADggIBAFlj3IFmgiFz5lvTzFTRizhVofhTJsGr14Yfkuc7 +UrXPuXOwJomd4uot2d/VIeGJpfnuS84qGdmQyGewGTJ9inatHsGZgHl9NHNWRwKZ +lTKTbBiq7aqgtUSFa06v202wpzU+1kadxJJePrbABxiXVfOmIW/a1a4hPNcT3syH +FIEg1+CGsp71UNjBuwg3JTKWna0sLSKcxLOSOvX1fzxK5djzVpEsvQMB4PSAzXca +vENgg2ErTwgTA+4s6rRtiBF9pAusN1QVuBahYP3ftrY6f3ycS4K65GnqscyfvKt5 +YgjtEKO3ZeeX8NpubMbzC+0Z6tVKfPFk/9TXuJtwvVeqow0YMrLLyRiYvK7EzJ97 +rrkxoKnHYQSZ+rH2tZ5SE392/rfk1PJL0cdHnkpDkUDO+8cKsFjjYKAQSNC52sKX +74AVh6wMwxYwVZZJf2/2XxkjMWWhKNejsZhUkTISSmiLs+qPe3L67IM7GyKm9/m6 +R3r8x6NGjhTsKH64iYJg7AeKeax4b2e4hBb6GXFftyOs7unpEOIVkJJgM6gh3mwn +R7v4gwFbLKADKt1vHuerSZMiTuNTGhSfCeDM53XI/mjZl2HeuCKP1mCDLlaO+gZR +Q/G+E0sBKgEX4xTkAc3kgkuQGfExdGtnN2U2ehF80lBHB8+2y2E+xWWXih/ZyIcW +wOx+ +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBDCCA+ygAwIBAgIQM4C8g5iFRucSWdC8EdqHeDANBgkqhkiG9w0BAQwFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGV1LWNlbnRyYWwtMSBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjEwNTIxMjIyODI2WhgPMjEyMTA1MjEyMzI4MjZaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgZXUtY2VudHJhbC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANeTsD/u +6saPiY4Sg0GlJlMXMBltnrcGAEkwq34OKQ0bCXqcoNJ2rcAMmuFC5x9Ho1Y3YzB7 +NO2GpIh6bZaO76GzSv4cnimcv9n/sQSYXsGbPD+bAtnN/RvNW1avt4C0q0/ghgF1 +VFS8JihIrgPYIArAmDtGNEdl5PUrdi9y6QGggbRfidMDdxlRdZBe1C18ZdgERSEv +UgSTPRlVczONG5qcQkUGCH83MMqL5MKQiby/Br5ZyPq6rxQMwRnQ7tROuElzyYzL +7d6kke+PNzG1mYy4cbYdjebwANCtZ2qYRSUHAQsOgybRcSoarv2xqcjO9cEsDiRU +l97ToadGYa4VVERuTaNZxQwrld4mvzpyKuirqZltOqg0eoy8VUsaRPL3dc5aChR0 +dSrBgRYmSAClcR2/2ZCWpXemikwgt031Dsc0A/+TmVurrsqszwbr0e5xqMow9LzO +MI/JtLd0VFtoOkL/7GG2tN8a+7gnLFxpv+AQ0DH5n4k/BY/IyS+H1erqSJhOTQ11 +vDOFTM5YplB9hWV9fp5PRs54ILlHTlZLpWGs3I2BrJwzRtg/rOlvsosqcge9ryai +AKm2j+JBg5wJ19R8oxRy8cfrNTftZePpISaLTyV2B16w/GsSjqixjTQe9LRN2DHk +cC+HPqYyzW2a3pUVyTGHhW6a7YsPBs9yzt6hAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFIqA8QkOs2cSirOpCuKuOh9VDfJfMA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAOUI90mEIsa+vNJku0iUwdBMnHiO4gm7E +5JloP7JG0xUr7d0hypDorMM3zVDAL+aZRHsq8n934Cywj7qEp1304UF6538ByGdz +tkfacJsUSYfdlNJE9KbA4T+U+7SNhj9jvePpVjdQbhgzxITE9f8CxY/eM40yluJJ +PhbaWvOiRagzo74wttlcDerzLT6Y/JrVpWhnB7IY8HvzK+BwAdaCsBUPC3HF+kth +CIqLq7J3YArTToejWZAp5OOI6DLPM1MEudyoejL02w0jq0CChmZ5i55ElEMnapRX +7GQTARHmjgAOqa95FjbHEZzRPqZ72AtZAWKFcYFNk+grXSeWiDgPFOsq6mDg8DDB +0kfbYwKLFFCC9YFmYzR2YrWw2NxAScccUc2chOWAoSNHiqBbHR8ofrlJSWrtmKqd +YRCXzn8wqXnTS3NNHNccqJ6dN+iMr9NGnytw8zwwSchiev53Fpc1mGrJ7BKTWH0t +ZrA6m32wzpMymtKozlOPYoE5mtZEzrzHEXfa44Rns7XIHxVQSXVWyBHLtIsZOrvW +U5F41rQaFEpEeUQ7sQvqUoISfTUVRNDn6GK6YaccEhCji14APLFIvhRQUDyYMIiM +4vll0F/xgVRHTgDVQ8b8sxdhSYlqB4Wc2Ym41YRz+X2yPqk3typEZBpc4P5Tt1/N +89cEIGdbjsA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQYjbPSg4+RNRD3zNxO1fuKDANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUyNDIwNTkyMVoYDzIwNjEwNTI0MjE1OTIxWjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGV1LW5vcnRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA179eQHxcV0YL +XMkqEmhSBazHhnRVd8yICbMq82PitE3BZcnv1Z5Zs/oOgNmMkOKae4tCXO/41JCX +wAgbs/eWWi+nnCfpQ/FqbLPg0h3dqzAgeszQyNl9IzTzX4Nd7JFRBVJXPIIKzlRf ++GmFsAhi3rYgDgO27pz3ciahVSN+CuACIRYnA0K0s9lhYdddmrW/SYeWyoB7jPa2 +LmWpAs7bDOgS4LlP2H3eFepBPgNufRytSQUVA8f58lsE5w25vNiUSnrdlvDrIU5n +Qwzc7NIZCx4qJpRbSKWrUtbyJriWfAkGU7i0IoainHLn0eHp9bWkwb9D+C/tMk1X +ERZw2PDGkwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBSFmR7s +dAblusFN+xhf1ae0KUqhWTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAHsXOpjPMyH9lDhPM61zYdja1ebcMVgfUvsDvt+w0xKMKPhBzYDMs/cFOi1N +Q8LV79VNNfI2NuvFmGygcvTIR+4h0pqqZ+wjWl3Kk5jVxCrbHg3RBX02QLumKd/i +kwGcEtTUvTssn3SM8bgM0/1BDXgImZPC567ciLvWDo0s/Fe9dJJC3E0G7d/4s09n +OMdextcxFuWBZrBm/KK3QF0ByA8MG3//VXaGO9OIeeOJCpWn1G1PjT1UklYhkg61 +EbsTiZVA2DLd1BGzfU4o4M5mo68l0msse/ndR1nEY6IywwpgIFue7+rEleDh6b9d +PYkG1rHVw2I0XDG4o17aOn5E94I= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQC6W4HFghUkkgyQw14a6JljANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIyMDUyMzE4MTYzMloYDzIwNjIwNTIzMTkxNjMyWjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGV1LXNvdXRoLTIgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAiM/t4FV2R9Nx +UQG203UY83jInTa/6TMq0SPyg617FqYZxvz2kkx09x3dmxepUg9ttGMlPgjsRZM5 +LCFEi1FWk+hxHzt7vAdhHES5tdjwds3aIkgNEillmRDVrUsbrDwufLaa+MMDO2E1 +wQ/JYFXw16WBCCi2g1EtyQ2Xp+tZDX5IWOTnvhZpW8vVDptZ2AcJ5rMhfOYO3OsK +5EF0GGA5ldzuezP+BkrBYGJ4wVKGxeaq9+5AT8iVZrypjwRkD7Y5CurywK3+aBwm +s9Q5Nd8t45JCOUzYp92rFKsCriD86n/JnEvgDfdP6Hvtm0/DkwXK40Wz2q0Zrd0k +mjP054NRPwIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRR7yqd +SfKcX2Q8GzhcVucReIpewTAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAEszBRDwXcZyNm07VcFwI1Im94oKwKccuKYeJEsizTBsVon8VpEiMwDs+yGu +3p8kBhvkLwWybkD/vv6McH7T5b9jDX2DoOudqYnnaYeypsPH/00Vh3LvKagqzQza +orWLx+0tLo8xW4BtU+Wrn3JId8LvAhxyYXTn9bm+EwPcStp8xGLwu53OPD1RXYuy +uu+3ps/2piP7GVfou7H6PRaqbFHNfiGg6Y+WA0HGHiJzn8uLmrRJ5YRdIOOG9/xi +qTmAZloUNM7VNuurcMM2hWF494tQpsQ6ysg2qPjbBqzlGoOt3GfBTOZmqmwmqtam +K7juWM/mdMQAJ3SMlE5wI8nVdx4= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICrjCCAjSgAwIBAgIRAL9SdzVPcpq7GOpvdGoM80IwCgYIKoZIzj0EAwMwgZYx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTEvMC0GA1UEAwwmQW1h +em9uIFJEUyBldS13ZXN0LTEgUm9vdCBDQSBFQ0MzODQgRzExEDAOBgNVBAcMB1Nl +YXR0bGUwIBcNMjEwNTIwMTY1ODA3WhgPMjEyMTA1MjAxNzU4MDdaMIGWMQswCQYD +VQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjETMBEG +A1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExLzAtBgNVBAMMJkFtYXpvbiBS +RFMgZXUtd2VzdC0xIFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQHDAdTZWF0dGxl +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEJWDgXebvwjR+Ce+hxKOLbnsfN5W5dOlP +Zn8kwWnD+SLkU81Eac/BDJsXGrMk6jFD1vg16PEkoSevsuYWlC8xR6FmT6F6pmeh +fsMGOyJpfK4fyoEPhKeQoT23lFIc5Orjo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0G +A1UdDgQWBBSVNAN1CHAz0eZ77qz2adeqjm31TzAOBgNVHQ8BAf8EBAMCAYYwCgYI +KoZIzj0EAwMDaAAwZQIxAMlQeHbcjor49jqmcJ9gRLWdEWpXG8thIf6zfYQ/OEAg +d7GDh4fR/OUk0VfjsBUN/gIwZB0bGdXvK38s6AAE/9IT051cz/wMe9GIrX1MnL1T +1F5OqnXJdiwfZRRTHsRQ/L00 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGBDCCA+ygAwIBAgIQalr16vDfX4Rsr+gfQ4iVFDANBgkqhkiG9w0BAQwFADCB +mjELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTMwMQYDVQQDDCpB +bWF6b24gUkRTIGV1LWNlbnRyYWwtMiBSb290IENBIFJTQTQwOTYgRzExEDAOBgNV +BAcMB1NlYXR0bGUwIBcNMjIwNjA2MjEyNTIzWhgPMjEyMjA2MDYyMjI1MjNaMIGa +MQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5j +LjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMzAxBgNVBAMMKkFt +YXpvbiBSRFMgZXUtY2VudHJhbC0yIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANbHbFg7 +2VhZor1YNtez0VlNFaobS3PwOMcEn45BE3y7HONnElIIWXGQa0811M8V2FnyqnE8 +Z5aO1EuvijvWf/3D8DPZkdmAkIfh5hlZYY6Aatr65kEOckwIAm7ZZzrwFogYuaFC +z/q0CW+8gxNK+98H/zeFx+IxiVoPPPX6UlrLvn+R6XYNERyHMLNgoZbbS5gGHk43 +KhENVv3AWCCcCc85O4rVd+DGb2vMVt6IzXdTQt6Kih28+RGph+WDwYmf+3txTYr8 +xMcCBt1+whyCPlMbC+Yn/ivtCO4LRf0MPZDRQrqTTrFf0h/V0BGEUmMGwuKgmzf5 +Kl9ILdWv6S956ioZin2WgAxhcn7+z//sN++zkqLreSf90Vgv+A7xPRqIpTdJ/nWG +JaAOUofBfsDsk4X4SUFE7xJa1FZAiu2lqB/E+y7jnWOvFRalzxVJ2Y+D/ZfUfrnK +4pfKtyD1C6ni1celrZrAwLrJ3PoXPSg4aJKh8+CHex477SRsGj8KP19FG8r0P5AG +8lS1V+enFCNvT5KqEBpDZ/Y5SQAhAYFUX+zH4/n4ql0l/emS+x23kSRrF+yMkB9q +lhC/fMk6Pi3tICBjrDQ8XAxv56hfud9w6+/ljYB2uQ1iUYtlE3JdIiuE+3ws26O8 +i7PLMD9zQmo+sVi12pLHfBHQ6RRHtdVRXbXRAgMBAAGjQjBAMA8GA1UdEwEB/wQF +MAMBAf8wHQYDVR0OBBYEFBFot08ipEL9ZUXCG4lagmF53C0/MA4GA1UdDwEB/wQE +AwIBhjANBgkqhkiG9w0BAQwFAAOCAgEAi2mcZi6cpaeqJ10xzMY0F3L2eOKYnlEQ +h6QyhmNKCUF05q5u+cok5KtznzqMwy7TFOZtbVHl8uUX+xvgq/MQCxqFAnuStBXm +gr2dg1h509ZwvTdk7TDxGdftvPCfnPNJBFbMSq4CZtNcOFBg9Rj8c3Yj+Qvwd56V +zWs65BUkDNJrXmxdvhJZjUkMa9vi/oFN+M84xXeZTaC5YDYNZZeW9706QqDbAVES +5ulvKLavB8waLI/lhRBK5/k0YykCMl0A8Togt8D1QsQ0eWWbIM8/HYJMPVFhJ8Wj +vT1p/YVeDA3Bo1iKDOttgC5vILf5Rw1ZEeDxjf/r8A7VS13D3OLjBmc31zxRTs3n +XvHKP9MieQHn9GE44tEYPjK3/yC6BDFzCBlvccYHmqGb+jvDEXEBXKzimdC9mcDl +f4BBQWGJBH5jkbU9p6iti19L/zHhz7qU6UJWbxY40w92L9jS9Utljh4A0LCTjlnR +NQUgjnGC6K+jkw8hj0LTC5Ip87oqoT9w7Av5EJ3VJ4hcnmNMXJJ1DkWYdnytcGpO +DMVITQzzDZRwhbitCVPHagTN2wdi9TEuYE33J0VmFeTc6FSI50wP2aOAZ0Q1/8Aj +bxeM5jS25eaHc2CQAuhrc/7GLnxOcPwdWQb2XWT8eHudhMnoRikVv/KSK3mf6om4 +1YfpdH2jp30= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIID/jCCAuagAwIBAgIQTDc+UgTRtYO7ZGTQ8UWKDDANBgkqhkiG9w0BAQsFADCB +lzELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTAwLgYDVQQDDCdB +bWF6b24gUkRTIGV1LXdlc3QtMiBSb290IENBIFJTQTIwNDggRzExEDAOBgNVBAcM +B1NlYXR0bGUwIBcNMjEwNTIxMjI0NjI0WhgPMjA2MTA1MjEyMzQ2MjRaMIGXMQsw +CQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywgSW5jLjET +MBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExMDAuBgNVBAMMJ0FtYXpv +biBSRFMgZXUtd2VzdC0yIFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4GA1UEBwwHU2Vh +dHRsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM1oGtthQ1YiVIC2 +i4u4swMAGxAjc/BZp0yq0eP5ZQFaxnxs7zFAPabEWsrjeDzrRhdVO0h7zskrertP +gblGhfD20JfjvCHdP1RUhy/nzG+T+hn6Takan/GIgs8grlBMRHMgBYHW7tklhjaH +3F7LujhceAHhhgp6IOrpb6YTaTTaJbF3GTmkqxSJ3l1LtEoWz8Al/nL/Ftzxrtez +Vs6ebpvd7sw37sxmXBWX2OlvUrPCTmladw9OrllGXtCFw4YyLe3zozBlZ3cHzQ0q +lINhpRcajTMfZrsiGCkQtoJT+AqVJPS2sHjqsEH8yiySW9Jbq4zyMbM1yqQ2vnnx +MJgoYMcCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUaQG88UnV +JPTI+Pcti1P+q3H7pGYwDgYDVR0PAQH/BAQDAgGGMA0GCSqGSIb3DQEBCwUAA4IB +AQBAkgr75V0sEJimC6QRiTVWEuj2Khy7unjSfudbM6zumhXEU2/sUaVLiYy6cA/x +3v0laDle6T07x9g64j5YastE/4jbzrGgIINFlY0JnaYmR3KZEjgi1s1fkRRf3llL +PJm9u4Q1mbwAMQK/ZjLuuRcL3uRIHJek18nRqT5h43GB26qXyvJqeYYpYfIjL9+/ +YiZAbSRRZG+Li23cmPWrbA1CJY121SB+WybCbysbOXzhD3Sl2KSZRwSw4p2HrFtV +1Prk0dOBtZxCG9luf87ultuDZpfS0w6oNBAMXocgswk24ylcADkkFxBWW+7BETn1 +EpK+t1Lm37mU4sxtuha00XAi +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIEADCCAuigAwIBAgIQcY44/8NUvBwr6LlHfRy7KjANBgkqhkiG9w0BAQsFADCB +mDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChB +bWF6b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTIxMDUxOTE4MjcxOFoYDzIwNjEwNTE5MTkyNzE4WjCBmDEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTEwLwYDVQQDDChBbWF6 +b24gUkRTIGV1LXNvdXRoLTEgUm9vdCBDQSBSU0EyMDQ4IEcxMRAwDgYDVQQHDAdT +ZWF0dGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0UaBeC+Usalu +EtXnV7+PnH+gi7/71tI/jkKVGKuhD2JDVvqLVoqbMHRh3+wGMvqKCjbHPcC2XMWv +566fpAj4UZ9CLB5fVzss+QVNTl+FH2XhEzigopp+872ajsNzcZxrMkifxGb4i0U+ +t0Zi+UrbL5tsfP2JonKR1crOrbS6/DlzHBjIiJazGOQcMsJjNuTOItLbMohLpraA +/nApa3kOvI7Ufool1/34MG0+wL3UUA4YkZ6oBJVxjZvvs6tI7Lzz/SnhK2widGdc +snbLqBpHNIZQSorVoiwcFaRBGYX/uzYkiw44Yfa4cK2V/B5zgu1Fbr0gbI2am4eh +yVYyg4jPawIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBS9gM1m +IIjyh9O5H/7Vj0R/akI7UzAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQAD +ggEBAF0Sm9HC2AUyedBVnwgkVXMibnYChOzz7T+0Y+fOLXYAEXex2s8oqGeZdGYX +JHkjBn7JXu7LM+TpTbPbFFDoc1sgMguD/ls+8XsqAl1CssW+amryIL+jfcfbgQ+P +ICwEUD9hGdjBgJ5WcuS+qqxHsEIlFNci3HxcxfBa9VsWs5TjI7Vsl4meL5lf7ZyL +wDV7dHRuU+cImqG1MIvPRIlvPnT7EghrCYi2VCPhP2pM/UvShuwVnkz4MJ29ebIk +WR9kpblFxFdE92D5UUvMCjC2kmtgzNiErvTcwIvOO9YCbBHzRB1fFiWrXUHhJWq9 +IkaxR5icb/IpAV0A1lYZEWMVsfQ= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGATCCA+mgAwIBAgIRAMa0TPL+QgbWfUPpYXQkf8wwDQYJKoZIhvcNAQEMBQAw +gZgxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwo +QW1hem9uIFJEUyBldS1ub3J0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UE +BwwHU2VhdHRsZTAgFw0yMTA1MjQyMTAzMjBaGA8yMTIxMDUyNDIyMDMyMFowgZgx +CzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJbmMu +MRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTExMC8GA1UEAwwoQW1h +em9uIFJEUyBldS1ub3J0aC0xIFJvb3QgQ0EgUlNBNDA5NiBHMTEQMA4GA1UEBwwH +U2VhdHRsZTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANhS9LJVJyWp +6Rudy9t47y6kzvgnFYDrvJVtgEK0vFn5ifdlHE7xqMz4LZqWBFTnS+3oidwVRqo7 +tqsuuElsouStO8m315/YUzKZEPmkw8h5ufWt/lg3NTCoUZNkB4p4skr7TspyMUwE +VdlKQuWTCOLtofwmWT+BnFF3To6xTh3XPlT3ssancw27Gob8kJegD7E0TSMVsecP +B8je65+3b8CGwcD3QB3kCTGLy87tXuS2+07pncHvjMRMBdDQQQqhXWsRSeUNg0IP +xdHTWcuwMldYPWK5zus9M4dCNBDlmZjKdcZZVUOKeBBAm7Uo7CbJCk8r/Fvfr6mw +nXXDtuWhqn/WhJiI/y0QU27M+Hy5CQMxBwFsfAjJkByBpdXmyYxUgTmMpLf43p7H +oWfH1xN0cT0OQEVmAQjMakauow4AQLNkilV+X6uAAu3STQVFRSrpvMen9Xx3EPC3 +G9flHueTa71bU65Xe8ZmEmFhGeFYHY0GrNPAFhq9RThPRY0IPyCZe0Th8uGejkek +jQjm0FHPOqs5jc8CD8eJs4jSEFt9lasFLVDcAhx0FkacLKQjGHvKAnnbRwhN/dF3 +xt4oL8Z4JGPCLau056gKnYaEyviN7PgO+IFIVOVIdKEBu2ASGE8/+QJB5bcHefNj +04hEkDW0UYJbSfPpVbGAR0gFI/QpycKnAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMB +Af8wHQYDVR0OBBYEFFMXvvjoaGGUcul8GA3FT05DLbZcMA4GA1UdDwEB/wQEAwIB +hjANBgkqhkiG9w0BAQwFAAOCAgEAQLwFhd2JKn4K/6salLyIA4mP58qbA/9BTB/r +D9l0bEwDlVPSdY7R3gZCe6v7SWLfA9RjE5tdWDrQMi5IU6W2OVrVsZS/yGJfwnwe +a/9iUAYprA5QYKDg37h12XhVsDKlYCekHdC+qa5WwB1SL3YUprDLPWeaIQdg+Uh2 ++LxvpZGoxoEbca0fc7flwq9ke/3sXt/3V4wJDyY6AL2YNdjFzC+FtYjHHx8rYxHs +aesP7yunuN17KcfOZBBnSFRrx96k+Xm95VReTEEpwiBqAECqEpMbd+R0mFAayMb1 +cE77GaK5yeC2f67NLYGpkpIoPbO9p9rzoXLE5GpSizMjimnz6QCbXPFAFBDfSzim +u6azp40kEUO6kWd7rBhqRwLc43D3TtNWQYxMve5mTRG4Od+eMKwYZmQz89BQCeqm +aZiJP9y9uwJw4p/A5V3lYHTDQqzmbOyhGUk6OdpdE8HXs/1ep1xTT20QDYOx3Ekt +r4mmNYfH/8v9nHNRlYJOqFhmoh1i85IUl5IHhg6OT5ZTTwsGTSxvgQQXrmmHVrgZ +rZIqyBKllCgVeB9sMEsntn4bGLig7CS/N1y2mYdW/745yCLZv2gj0NXhPqgEIdVV +f9DhFD4ohE1C63XP0kOQee+LYg/MY5vH8swpCSWxQgX5icv5jVDz8YTdCKgUc5u8 +rM2p0kk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIECDCCAvCgAwIBAgIQYWF2Yb4HiQV2OJlcN2BhUDANBgkqhkiG9w0BAQsFADCB +nDELMAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIElu +Yy4xEzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTUwMwYDVQQDDCxB +bWF6b24gUkRTIGFwLXNvdXRoZWFzdC02IFJvb3QgQ0EgUlNBMjA0OCBHMTEQMA4G +A1UEBwwHU2VhdHRsZTAgFw0yNTA1MjAwMTQzNTdaGA8yMDY1MDUyMDAyNDM1N1ow +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNiBSb290IENBIFJTQTIwNDggRzExEDAO +BgNVBAcMB1NlYXR0bGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCp +7R+Rclm+GQyjwytLA5lg6AH6IzXEsp8Q5ZtYzjYizhzOWhdqYfYQsv43IJjzY3Cd +Wwtp+/+WOegfFntFfEN7Cy2zP+Ib18TT4alkalo45mbTjcKAflDhLuwLZCrXq7Sk +ECm0lveumAlVXXUW0mFEOd48XvTzvY1tNO8hGlN2l1n2wG6TfOU317q+PLF2F79J +iAqVt2ZUIvFgwIS3qfQK0n6c5OKzPrpsvfecFZePhL20YH/2yCNxGb6rNBRErXxV +oNICqsl03LNOcgpm1WvXINWLJT/le6IEAbHMgMeG3DIG3q4sYiEyE5qi8SSR6S5P +ky3+mtWZL/ZWp2HFQ8xZAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0O +BBYEFAlH8qgMxdi5cVGO+FTynv4hC3sOMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG +9w0BAQsFAAOCAQEAS+oW1l/6C3lh4WWE2aBGCdC713mjpBjgTjzpUnyIdQDaBuzD +vHpm+p6bK+a4WP/jtjniUvZhyYD9VbaQhHYa9nSX+LHIkF1cxMTqHMjK8dgnenF7 +KhUbmaERlk7MPAGLoRKngoS8NRjizFiBLUTKn62KvI4DKiswxWJklpd3HIn9qq8q +eR2PLoHHc4WJ/E1UXpCIt9dbWwB22xHp0Gb0vFcdWE76uwR5C4x8eAqct8455NrC +gh3UCCsNEu57aUjNRA+PwvCUDEsJj72mKCzUNGG4bTDCqY1DMlx2TaXByxs7Ctwx +wiK/vWV+ZChc5mMDzf9csrOQzDPPJpiUfp11rg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIGCTCCA/GgAwIBAgIRAKEoQBLrmKhwHJppfnj9miEwDQYJKoZIhvcNAQEMBQAw +gZwxCzAJBgNVBAYTAlVTMSIwIAYDVQQKDBlBbWF6b24gV2ViIFNlcnZpY2VzLCBJ +bmMuMRMwEQYDVQQLDApBbWF6b24gUkRTMQswCQYDVQQIDAJXQTE1MDMGA1UEAwws +QW1hem9uIFJEUyBhcC1zb3V0aGVhc3QtNiBSb290IENBIFJTQTQwOTYgRzExEDAO +BgNVBAcMB1NlYXR0bGUwIBcNMjUwNTIwMDE0NDAyWhgPMjEyNTA1MjAwMjQ0MDJa +MIGcMQswCQYDVQQGEwJVUzEiMCAGA1UECgwZQW1hem9uIFdlYiBTZXJ2aWNlcywg +SW5jLjETMBEGA1UECwwKQW1hem9uIFJEUzELMAkGA1UECAwCV0ExNTAzBgNVBAMM +LEFtYXpvbiBSRFMgYXAtc291dGhlYXN0LTYgUm9vdCBDQSBSU0E0MDk2IEcxMRAw +DgYDVQQHDAdTZWF0dGxlMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA +m5LnhN6bm6BRlvFolsYe4EuK0uIWhLFItpCs4GbfAyQMJUEFc9pUIayg9+N6/B7q +4VVRvIDYfqEIjywkWaJwBu58/B2EE47iLTHvAU9HyxojKvucFFHRmrrsQG5Fyn/0 +LNDDiOpik6BTKp4b32JnEe+X5ASd4pU7ibOSec6m+mISIEIAsjaRw8WdcrglJpIu +69NKoM9QJRkiECWe6MSxwuTdNgh5SouuABThg7ROPrxlxt1pkwlN1nA1Czb2GrBe +XV4Be1h1djEC7UoehaVxOs4VFJfOuHg72YHHf3zVhtpJAGR0/HypOcFnXd+DXoXL +wjMUm3mDNyMEJLHQUwYA99QheEl7+aprpe9gM3RQ85sg5XQJ3YUOPuErqDnnVveH +bckOkkTYTfSZUY9fpQmX7YxFv/QN6UpMXh7pzxOeM+VTvsH3JH0HweFNWCSALcbg +/no7Z8mIkOEE9gMCzqoFz0uILQzFM/n5yrAnmCzU+EM5HC3+oxO9mGGwiNp/LQUY +nWqkHHLQjurxmqlLQoBofpYiyi9vdh3IbULtYZJTUT87OA5BsKOH5Vaq6J9vw701 +1zCQnpg8B6tp7+Z2/vCRr1sY7tNmh8gQoiwFfx9Qpkc83/mlyoenvqeevQ42bok8 +0KZvJ/dYKNRZLO7kX8kHZTWkzGOYpfI5djYCWAAlPRUCAwEAAaNCMEAwDwYDVR0T +AQH/BAUwAwEB/zAdBgNVHQ4EFgQUri9+CM+fxgpdvEDFDhb0smAKHy4wDgYDVR0P +AQH/BAQDAgGGMA0GCSqGSIb3DQEBDAUAA4ICAQAUOKOXItwvve85tuPTLBNVP18f +40rf8RPhH1JFKMePXcQ2gav7t9KwxnUk6iqVo/Pt/knUeXJ+uLMRHfjzVpadlUoT +6zH+TndeljwG1QFuYOxwYI+VNwZnb7lF0O0cADkhDfc8/QvG/ZkrAx7s2kWWC8by +fWpsjDt3xDD3ump+Qr+o0jpZyXPbWZQeIo5wmnMOc0PXxeFlIOCeUaJDtaJMRHBq +CpdPT5xkNaRJnZzKJiUZx3NZe4du5lVuz5gT9YmooFGRFHU26hANkHQGxsDTkTx9 +Mg+5QXeDFQ7MnvFmFu2rb2cizZuKL9ZpiLE5f2/Hcd7apY7z8dSJhDWhvmTeeJPr +GbplpepGVt5BIhg395guJ8Kww4Ux7a/8ERdQHkRd+ykspQNfveqLl+IP8s/snLDP +lcILGYvHLq02HGrM8chU5mzNe5lyYmSI3cYxEZdDMiuOVpLlNuNUGadyafFx13Y0 +a6wDpXcot9Qh6W+JFpq0TbgbVezkk+1YzDJK7ShBQqckPlGHl9uyWjX3xuC4KD6g +epx7UmkEP56BhvLMoT+o3KDBts9ujuJIZt+uGoo+rYIZaSuGLKdA60B4ZS3ONrws +G3+o22y0lMvHGMHiwCIH+RQQBt5brmfkjD+QJh5K/1iJaZtFecgeuZnuar5W37k2 +IGstLzLjZODMnBQoSA== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICtzCCAj2gAwIBAgIQbliDBFi6VBnhtzveX+/ALjAKBggqhkjOPQQDAzCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC02IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMCAXDTI1MDUyMDAxNDQwN1oYDzIxMjUwNTIwMDI0NDA3WjCBmzEL +MAkGA1UEBhMCVVMxIjAgBgNVBAoMGUFtYXpvbiBXZWIgU2VydmljZXMsIEluYy4x +EzARBgNVBAsMCkFtYXpvbiBSRFMxCzAJBgNVBAgMAldBMTQwMgYDVQQDDCtBbWF6 +b24gUkRTIGFwLXNvdXRoZWFzdC02IFJvb3QgQ0EgRUNDMzg0IEcxMRAwDgYDVQQH +DAdTZWF0dGxlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEf1cgi4GHIBFSOQMW6NPm +/MUlTNs34ONxLDenJ/m3tkuMjoDSrimTAwQcCoIUrlwOpauaW4le0aFb+SUw6IEW +axCohHeASRW5P7rV/MBgiSWcKxgAj0XSWXaGMXM9ycfso0IwQDAPBgNVHRMBAf8E +BTADAQH/MB0GA1UdDgQWBBTTHGkZp0gQNJj2xYOEKgB+WoGw/zAOBgNVHQ8BAf8E +BAMCAYYwCgYIKoZIzj0EAwMDaAAwZQIwSZoT6sdk5Duk2oqvI9kbYDH84vut3CmX +EtJwpeusN5Uv0fm//WfNwXZmZ3RB7L9qAjEAqFQK/AKtmyTePw4w+LQVljNOh1UQ +sfb8fO9Mt9eYGJ0i1QYo60u/qIypql6ZsIvW +-----END CERTIFICATE----- diff --git a/project/aitoearn-monorepo/eslint.config.mjs b/project/aitoearn-monorepo/eslint.config.mjs new file mode 100644 index 000000000..367502256 --- /dev/null +++ b/project/aitoearn-monorepo/eslint.config.mjs @@ -0,0 +1,88 @@ +import antfu from '@antfu/eslint-config' +import * as nx from '@nx/eslint-plugin' +import importPlugin from 'eslint-plugin-import' + +export default antfu( + { + formatters: true, + ignores: [ + '**/dist', + ], + plugins: { + imports: importPlugin, + }, + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: 'CallExpression[callee.type=\'MemberExpression\'][callee.object.name=\'Logger\']', + message: '禁止直接使用 Logger 的静态方法,请创建实例后使用(例如 this.logger.log())。', + }, + ], + 'no-void': ['off'], + 'dot-notation': ['off'], + 'new-cap': ['off'], + 'ts/no-unused-vars': ['warn', { ignoreRestSiblings: true, argsIgnorePattern: '^_' }], + 'ts/consistent-type-imports': ['off'], + 'ts/no-inferrable-types': ['error', { ignoreProperties: true }], + 'ts/no-explicit-any': ['warn'], + 'jsdoc/require-returns-description': ['off'], + 'node/prefer-global/buffer': ['off'], + 'node/prefer-global/process': ['off'], + 'imports/no-absolute-path': ['error'], + }, + }, + ...nx.configs['flat/base'], + { + files: [ + '**/*.ts', + '**/*.tsx', + '**/*.js', + '**/*.jsx', + ], + rules: { + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [ + '^.*/eslint(\\.base)?\\.config\\.[cm]?[jt]s$', + ], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: [ + '*', + ], + }, + ], + }, + ], + }, + }, + { + files: [ + '**/*.ts', + '**/*.tsx', + '**/*.cts', + '**/*.mts', + ], + rules: { + 'no-console': ['error', { allow: [''] }], + }, + }, + { + files: [ + '**/*.ts', + '**/*.tsx', + '**/*.cts', + '**/*.mts', + '**/*.js', + '**/*.jsx', + '**/*.cjs', + '**/*.mjs', + ], + rules: { + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/README.md b/project/aitoearn-monorepo/libs/aitoearn-auth/README.md new file mode 100644 index 000000000..0ec085710 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/README.md @@ -0,0 +1,7 @@ +# nats-client + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build nats-client` to build the library. diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/eslint.config.mjs b/project/aitoearn-monorepo/libs/aitoearn-auth/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/package.json b/project/aitoearn-monorepo/libs/aitoearn-auth/package.json new file mode 100644 index 000000000..36d438b48 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/package.json @@ -0,0 +1,26 @@ +{ + "name": "@yikart/aitoearn-auth", + "type": "commonjs", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "11.1.7", + "@nestjs/jwt": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@yikart/common": "workspace:*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "rxjs": "^7.8.0" + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/project.json b/project/aitoearn-monorepo/libs/aitoearn-auth/project.json new file mode 100644 index 000000000..606c03ed4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/project.json @@ -0,0 +1,42 @@ +{ + "name": "aitoearn-auth", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/aitoearn-auth/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/aitoearn-auth", + "tsConfig": "libs/aitoearn-auth/tsconfig.lib.json", + "packageJson": "libs/aitoearn-auth/package.json", + "main": "libs/aitoearn-auth/src/index.ts", + "assets": ["libs/aitoearn-auth/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.config.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.config.ts new file mode 100644 index 000000000..0768b9806 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const aitoearnAuthConfigSchema = z.object({ + secret: z.string().default(''), + expiresIn: z.number().default(7 * 24 * 60 * 60), + internalToken: z.string(), +}) + +export class AitoearnAuthConfig extends createZodDto(aitoearnAuthConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.constants.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.constants.ts new file mode 100644 index 000000000..10a232784 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.constants.ts @@ -0,0 +1,2 @@ +export const IS_PUBLIC_KEY = Symbol('is_public') +export const IS_INTERNAL_KEY = Symbol('is_internal') diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.decorator.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.decorator.ts new file mode 100644 index 000000000..0071a3d1f --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, ExecutionContext, SetMetadata } from '@nestjs/common' +import { IS_INTERNAL_KEY, IS_PUBLIC_KEY } from './aitoearn-auth.constants' + +export const GetToken = createParamDecorator( + (_data: string, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest() + return req['user'] + }, +) + +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true) +export const Internal = () => SetMetadata(IS_INTERNAL_KEY, true) diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.guard.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.guard.ts new file mode 100644 index 000000000..9b380c2b1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.guard.ts @@ -0,0 +1,78 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { JwtService } from '@nestjs/jwt' +import { AitoearnAuthConfig } from './aitoearn-auth.config' +import { IS_INTERNAL_KEY, IS_PUBLIC_KEY } from './aitoearn-auth.constants' + +@Injectable() +export class AitoearnAuthGuard implements CanActivate { + private readonly logger = new Logger(AitoearnAuthGuard.name) + private readonly reflector = new Reflector() + private readonly secret: string + constructor( + private readonly jwtService: JwtService, + private readonly config: AitoearnAuthConfig, + ) { + this.secret = config.secret + } + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]) + + const request = context.switchToHttp().getRequest() + const token = this.extractTokenFromHeader(request) + if (!token) { + if (isPublic) { + return true + } + throw new UnauthorizedException() + } + + if (token === this.config.internalToken) { + const isInternal = this.reflector.getAllAndOverride(IS_INTERNAL_KEY, [ + context.getHandler(), + context.getClass(), + ]) + return !!isInternal + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.secret, + }) + // 以便我们可以在路由处理器中访问它 + request['user'] = payload + + this.logger.debug({ + message: 'token验证成功', + payload, + }) + } + catch (error) { + this.logger.debug({ + message: 'token验证失败', + error, + }) + + if (isPublic) + return true + + throw new UnauthorizedException() + } + return true + } + + private extractTokenFromHeader(request: any): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? [] + return type === 'Bearer' ? token : undefined + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.interface.ts new file mode 100644 index 000000000..a3909fc7a --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.interface.ts @@ -0,0 +1,6 @@ +export interface TokenInfo { + readonly id: string + readonly mail?: string + readonly name?: string + readonly exp?: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.module.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.module.ts new file mode 100644 index 000000000..9f60747a1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { JwtModule } from '@nestjs/jwt' +import { AitoearnAuthConfig } from './aitoearn-auth.config' +import { AitoearnAuthGuard } from './aitoearn-auth.guard' +import { AitoearnAuthService } from './aitoearn-auth.service' + +@Module({}) +export class AitoearnAuthModule { + static forRoot(config: AitoearnAuthConfig): DynamicModule { + return { + global: true, + module: AitoearnAuthModule, + imports: [ + JwtModule.register({ + secret: config.secret, + signOptions: { expiresIn: config.expiresIn }, + }), + ], + providers: [ + { + provide: AitoearnAuthConfig, + useValue: config, + }, + AitoearnAuthService, + { + provide: APP_GUARD, + useClass: AitoearnAuthGuard, + }, + ], + exports: [AitoearnAuthService], + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.service.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.service.ts new file mode 100644 index 000000000..51a783d14 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/aitoearn-auth.service.ts @@ -0,0 +1,47 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common' +import { JwtService } from '@nestjs/jwt' +import { TokenInfo } from './aitoearn-auth.interface' + +@Injectable() +export class AitoearnAuthService { + constructor(private readonly jwtService: JwtService) {} + + /** + * 生成Token + * @param tokenInfo + * @returns + */ + generateToken(tokenInfo: TokenInfo): string { + const payload: TokenInfo = { + mail: tokenInfo.mail, + id: tokenInfo.id, + name: tokenInfo.name, + } + + return this.jwtService.sign(payload) + } + + /** + * 重置Token + * @param tokenInfo + * @returns + */ + resetToken(tokenInfo: TokenInfo): string { + const payload: TokenInfo = { + mail: tokenInfo.mail, + id: tokenInfo.id, + name: tokenInfo.name, + } + return this.jwtService.sign(payload) + } + + decodeToken(token: string): TokenInfo { + token = token.replace('Bearer ', '') + try { + return this.jwtService.decode(token) + } + catch { + throw new UnauthorizedException() + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/src/index.ts b/project/aitoearn-monorepo/libs/aitoearn-auth/src/index.ts new file mode 100644 index 000000000..74d98d0c6 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/src/index.ts @@ -0,0 +1,5 @@ +export * from './aitoearn-auth.config' +export * from './aitoearn-auth.decorator' +export * from './aitoearn-auth.interface' +export * from './aitoearn-auth.module' +export * from './aitoearn-auth.service' diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.json b/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.lib.json b/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-auth/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/README.md b/project/aitoearn-monorepo/libs/aitoearn-queue/README.md new file mode 100644 index 000000000..a6672face --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/README.md @@ -0,0 +1,54 @@ +# @yikart/aitoearn-queue + +统一的队列管理模块,基于 BullMQ 实现。 + +## 功能 + +- 统一的队列服务(QueueService) +- 集中的队列名称管理(QueueName 枚举) +- 内置 Redis 连接管理 +- 类型安全的任务数据定义 + +## 使用方法 + +### 在应用中导入模块 + +```typescript +import { AitoearnQueueModule } from '../src/queue.module' + +@Module({ + imports: [ + AitoearnQueueModule.forRoot({ + redis: { + host: 'localhost', + port: 6379, + }, + prefix: '{bull}', + }), + ], +}) +export class AppModule {} +``` + +### 使用队列服务 + +```typescript +import { QueueService } from '../src/queue.module' + +@Injectable() +export class MyService { + constructor(private readonly queueService: QueueService) {} + + async addJob() { + await this.queueService.addMaterialGenerateJobs([ + { taskId: '123' } + ]) + } +} +``` + +## 注意事项 + +- 不要直接使用 `@InjectQueue` 装饰器,请使用 `QueueService` +- 不要直接导入 `bullmq` 的类型,队列模块已提供所需接口 +- Consumer(队列消费者)仍然保留在各应用中 diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/eslint.config.mjs b/project/aitoearn-monorepo/libs/aitoearn-queue/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/package.json b/project/aitoearn-monorepo/libs/aitoearn-queue/package.json new file mode 100644 index 000000000..db7ed9be2 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/package.json @@ -0,0 +1,27 @@ +{ + "name": "@yikart/aitoearn-queue", + "type": "commonjs", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/bullmq": "^11.0.0", + "@nestjs/common": "^11.0.0", + "@yikart/common": "workspace:*", + "@yikart/mongodb": "workspace:*", + "@yikart/redis": "workspace:*", + "bullmq": "^5.0.0", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "ioredis": "^5.7.0" + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/project.json b/project/aitoearn-monorepo/libs/aitoearn-queue/project.json new file mode 100644 index 000000000..2bb20a425 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/project.json @@ -0,0 +1,42 @@ +{ + "name": "aitoearn-queue", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/aitoearn-queue/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/aitoearn-queue", + "tsConfig": "libs/aitoearn-queue/tsconfig.lib.json", + "packageJson": "libs/aitoearn-queue/package.json", + "main": "libs/aitoearn-queue/src/index.ts", + "assets": ["libs/aitoearn-queue/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/index.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/index.ts new file mode 100644 index 000000000..526241e80 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/index.ts @@ -0,0 +1 @@ +export * from './queue-name.enum' diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/queue-name.enum.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/queue-name.enum.ts new file mode 100644 index 000000000..79f614116 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/enums/queue-name.enum.ts @@ -0,0 +1,41 @@ +/** + * 队列名称枚举 + * 统一管理所有队列的名称 + */ +export enum QueueName { + /** 素材生成队列 */ + MaterialGenerate = 'bull_material_generate', + + /** 发布任务队列 */ + PostPublish = 'post_publish', + + /** 发布媒体任务队列(Meta平台) */ + PostMediaTask = 'post_media_task', + + /** 任务自动审核队列 */ + TaskAudit = 'bull_aotu_task_audit', + + /** AI图片异步生成队列 */ + AiImageAsync = 'ai_image_async', + + /** 互动任务分发队列 */ + EngagementTaskDistribution = 'engagement_task_distribution', + + /** 评论回复任务队列 */ + EngagementReplyToComment = 'engagement_reply_to_comment_task', + + /** 云空间配置队列 */ + CloudspaceConfigure = 'cloudspace-configure', + + /** 云空间过期处理队列 */ + CloudspaceExpiration = 'cloudspace-expiration', + + /** 用户创建时推送任务队列 */ + TaskUserCreatePush = 'task_user_create_push', + + /** 用户画像上报队列 */ + TaskUserPortraitReport = 'task_user_portrait_report', + + /** 频道账号画像上报队列 */ + TaskAccountPortraitReport = 'task_account_portrait_report', +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/index.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/index.ts new file mode 100644 index 000000000..05da365e6 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/index.ts @@ -0,0 +1,14 @@ +// 导出枚举 +export * from './enums' + +// 导出接口 +export * from './interfaces' + +// 导出配置 +export * from './queue.config' + +// 导出模块 +export * from './queue.module' + +// 导出服务 +export * from './queue.service' diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/ai-image-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/ai-image-data.interface.ts new file mode 100644 index 000000000..a2abcbc7a --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/ai-image-data.interface.ts @@ -0,0 +1,26 @@ +import type { UserType } from '@yikart/common' +import type { AiLogChannel, AiLogType } from '@yikart/mongodb' + +/** + * AI图片异步生成任务数据 + */ +export interface AiImageData { + /** 日志ID */ + logId: string + /** 用户ID */ + userId: string + /** 用户类型 */ + userType: UserType + /** 模型名称 */ + model: string + /** 渠道 */ + channel?: AiLogChannel + /** 日志类型 */ + type: AiLogType + /** 计费 */ + pricing: number + /** 请求参数 */ + request: unknown + /** 任务类型 */ + taskType: 'generation' | 'edit' | 'md2card' | 'fireflyCard' +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/cloudspace-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/cloudspace-data.interface.ts new file mode 100644 index 000000000..56fd5d09e --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/cloudspace-data.interface.ts @@ -0,0 +1,17 @@ +/** + * 云空间配置任务数据 + */ +export interface CloudspaceConfigureData { + /** 云空间ID */ + cloudSpaceId: string +} + +/** + * 云空间过期处理任务数据 + */ +export interface CloudspaceExpirationData { + /** 云空间ID */ + cloudSpaceId: string + /** 其他任务参数 */ + [key: string]: any +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/engagement-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/engagement-data.interface.ts new file mode 100644 index 000000000..9819adc33 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/engagement-data.interface.ts @@ -0,0 +1,15 @@ +/** + * 互动任务分发数据(供 Consumer 使用) + */ +export interface EngagementTaskDistributionData { + taskId: string + attempts: number +} + +/** + * 评论回复任务数据(供 Consumer 使用) + */ +export interface EngagementReplyToCommentData { + taskId: string + attempts: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/index.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/index.ts new file mode 100644 index 000000000..abaa0dc39 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/index.ts @@ -0,0 +1,10 @@ +export * from './ai-image-data.interface' +export * from './cloudspace-data.interface' +export * from './engagement-data.interface' +export * from './material-generate-data.interface' +export * from './post-media-task-data.interface' +export * from './post-publish-data.interface' +export * from './task-account-portrait-data.interface' +export * from './task-audit-data.interface' +export * from './task-user-create-data.interface' +export * from './task-user-portrait-data.interface' diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/material-generate-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/material-generate-data.interface.ts new file mode 100644 index 000000000..9305117bc --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/material-generate-data.interface.ts @@ -0,0 +1,7 @@ +/** + * 素材生成任务数据 + */ +export interface MaterialGenerateData { + /** 任务ID */ + taskId: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-media-task-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-media-task-data.interface.ts new file mode 100644 index 000000000..c5f216ae9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-media-task-data.interface.ts @@ -0,0 +1,11 @@ +/** + * 发布媒体任务数据(Meta平台) + */ +export interface PostMediaTaskData { + /** 任务ID */ + taskId: string + /** 重试次数 */ + attempts: number + /** 任务ID(可选) */ + jobId?: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-publish-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-publish-data.interface.ts new file mode 100644 index 000000000..ca0376dd3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/post-publish-data.interface.ts @@ -0,0 +1,13 @@ +/** + * 发布任务数据 + */ +export interface PostPublishData { + /** 任务ID */ + taskId: string + /** 重试次数 */ + attempts: number + /** 任务ID(可选) */ + jobId?: string + /** 超时时间(毫秒,可选) */ + timeout?: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-account-portrait-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-account-portrait-data.interface.ts new file mode 100644 index 000000000..c41cfa3a8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-account-portrait-data.interface.ts @@ -0,0 +1,21 @@ +import { AccountType } from '@yikart/common' +import { AccountStatus } from '@yikart/mongodb' + +/** + * 频道账号画像上报数据接口 + */ +export interface TaskAccountPortraitData { + accountId?: string + userId?: string + type: AccountType // AccountType + uid: string // 频道平台唯一ID + avatar?: string + nickname?: string + status?: AccountStatus + contentTags?: Record + totalFollowers?: number + totalWorks?: number + totalViews?: number + totalLikes?: number + totalCollects?: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-audit-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-audit-data.interface.ts new file mode 100644 index 000000000..e7c6b2518 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-audit-data.interface.ts @@ -0,0 +1,7 @@ +/** + * 任务审核数据 + */ +export interface TaskAuditData { + /** 用户任务ID */ + userTaskId: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-create-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-create-data.interface.ts new file mode 100644 index 000000000..87d31b12b --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-create-data.interface.ts @@ -0,0 +1,7 @@ +/** + * 用户创建时推送任务的数据接口 + */ +export interface TaskUserCreateData { + /** 用户ID */ + userId: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-portrait-data.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-portrait-data.interface.ts new file mode 100644 index 000000000..96ccd48e9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/interfaces/task-user-portrait-data.interface.ts @@ -0,0 +1,16 @@ +/** + * 用户画像上报数据接口 + */ +export interface TaskUserPortraitData { + userId: string + name?: string + avatar?: string + status?: number + lastLoginTime?: string + contentTags?: Record + totalFollowers?: number + totalWorks?: number + totalViews?: number + totalLikes?: number + totalCollects?: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.config.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.config.ts new file mode 100644 index 000000000..8b98bf853 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.config.ts @@ -0,0 +1,32 @@ +import { createZodDto } from '@yikart/common' +import { redisConfigSchema } from '@yikart/redis' +import { z } from 'zod' + +/** + * Job 选项配置 Schema + */ +export const jobOptionsSchema = z.object({ + /** 完成后移除,默认 true */ + removeOnComplete: z.boolean().default(true), + /** 失败后移除,默认 true */ + removeOnFail: z.boolean().default(true), + /** 任务超时时间(毫秒),默认 5 分钟 */ + timeout: z.number().default(5 * 60000), +}) + +/** + * 队列配置 Schema + */ +export const queueConfigSchema = z.object({ + /** Redis 配置 */ + redis: redisConfigSchema, + /** 队列前缀,默认 '{bull}' */ + prefix: z.string().default('{bull}'), + /** Job 默认选项 */ + jobOptions: jobOptionsSchema.optional(), +}) + +/** + * 队列配置类 + */ +export class QueueConfig extends createZodDto(queueConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.module.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.module.ts new file mode 100644 index 000000000..9663dbe2a --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.module.ts @@ -0,0 +1,60 @@ +import type { DynamicModule } from '@nestjs/common' +import { BullModule } from '@nestjs/bullmq' +import { Module } from '@nestjs/common' +import { RedisModule } from '@yikart/redis' +import { Redis } from 'ioredis' +import { QueueName } from './enums' +import { QueueConfig } from './queue.config' +import { QueueService } from './queue.service' + +/** + * 队列模块 + * 提供统一的队列管理功能 + */ +@Module({}) +export class AitoearnQueueModule { + /** + * 创建队列模块 + * @param config 队列配置 + */ + static forRoot(config: QueueConfig): DynamicModule { + // 动态注册所有队列 + const queueModules = Object.values(QueueName).map((name) => { + // 可以在这里为特殊队列添加特殊配置 + // if (name === QueueName.SomeSpecialQueue) { + // return BullModule.registerQueue({ + // name, + // limiter: { duration: 10000, max: 100 }, + // }) + // } + return BullModule.registerQueue({ name }) + }) + + return { + global: true, + module: AitoearnQueueModule, + imports: [ + // 集成 Redis 模块 + RedisModule.forRoot(config.redis), + // 配置 Bull 连接 + BullModule.forRootAsync({ + useFactory: (redis: Redis) => ({ + prefix: config.prefix, + connection: redis, + }), + inject: [Redis], + }), + // 注册所有队列 + ...queueModules, + ], + providers: [ + { + provide: QueueConfig, + useValue: config, + }, + QueueService, + ], + exports: [QueueService], + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.service.ts b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.service.ts new file mode 100644 index 000000000..e0fe96a43 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/src/queue.service.ts @@ -0,0 +1,310 @@ +import type { Job, JobsOptions, Queue } from 'bullmq' +import type { + AiImageData, + CloudspaceConfigureData, + CloudspaceExpirationData, + EngagementReplyToCommentData, + EngagementTaskDistributionData, + MaterialGenerateData, + PostMediaTaskData, + PostPublishData, + TaskAccountPortraitData, + TaskAuditData, + TaskUserCreateData, + TaskUserPortraitData, +} from './interfaces' +import { InjectQueue } from '@nestjs/bullmq' +import { Injectable } from '@nestjs/common' +import { QueueName } from './enums' +import { QueueConfig } from './queue.config' + +/** + * 统一的队列服务 + * 提供所有队列的操作方法 + */ +@Injectable() +export class QueueService { + private readonly defaultOptions: JobsOptions + + constructor( + private config: QueueConfig, + @InjectQueue(QueueName.MaterialGenerate) + private materialGenerateQueue: Queue, + @InjectQueue(QueueName.PostPublish) + private postPublishQueue: Queue, + @InjectQueue(QueueName.PostMediaTask) + private postMediaTaskQueue: Queue, + @InjectQueue(QueueName.TaskAudit) + private taskAuditQueue: Queue, + @InjectQueue(QueueName.AiImageAsync) + private aiImageAsyncQueue: Queue, + @InjectQueue(QueueName.EngagementTaskDistribution) + private engagementTaskDistributionQueue: Queue, + @InjectQueue(QueueName.EngagementReplyToComment) + private engagementReplyToCommentQueue: Queue, + @InjectQueue(QueueName.CloudspaceConfigure) + private cloudspaceConfigureQueue: Queue, + @InjectQueue(QueueName.CloudspaceExpiration) + private cloudspaceExpirationQueue: Queue, + @InjectQueue(QueueName.TaskUserCreatePush) + private taskUserCreatePushQueue: Queue, + @InjectQueue(QueueName.TaskUserPortraitReport) + private taskUserPortraitReportQueue: Queue, + @InjectQueue(QueueName.TaskAccountPortraitReport) + private taskAccountPortraitReportQueue: Queue, + ) { + // 从配置中读取默认的 job options + this.defaultOptions = config.jobOptions || { + removeOnComplete: true, + removeOnFail: true, + timeout: 5 * 60000, + } + } + + /** + * 添加素材生成任务 + */ + async addMaterialGenerateJob(data: MaterialGenerateData, options?: JobsOptions) { + return await this.materialGenerateQueue.add('start', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取素材生成任务 + */ + async getMaterialGenerateJob(jobId: string): Promise | undefined> { + return await this.materialGenerateQueue.getJob(jobId) + } + + /** + * 移除素材生成任务 + */ + async removeMaterialGenerateJob(jobId: string) { + const job = await this.materialGenerateQueue.getJob(jobId) + if (job) { + await job.remove() + } + } + + /** + * 添加发布任务 + */ + async addPostPublishJob(data: PostPublishData, options?: JobsOptions) { + return await this.postPublishQueue.add('publish', data, { + ...this.defaultOptions, + jobId: data.jobId, + ...options, + }) + } + + /** + * 获取发布任务 + */ + async getPostPublishJob(jobId: string): Promise | undefined> { + return await this.postPublishQueue.getJob(jobId) + } + + /** + * 移除发布任务 + */ + async removePostPublishJob(jobId: string) { + const job = await this.postPublishQueue.getJob(jobId) + if (job) { + await job.remove() + } + } + + /** + * 关闭发布队列 + */ + async closePostPublishQueue() { + await this.postPublishQueue.close() + } + + /** + * 添加发布媒体任务 + */ + async addPostMediaTaskJob(data: PostMediaTaskData, options?: JobsOptions) { + return await this.postMediaTaskQueue.add('media', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取发布媒体任务 + */ + async getPostMediaTaskJob(jobId: string): Promise | undefined> { + return await this.postMediaTaskQueue.getJob(jobId) + } + + /** + * 添加任务审核任务 + */ + async addTaskAuditJob(data: TaskAuditData, options?: JobsOptions) { + return await this.taskAuditQueue.add('audit', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取任务审核任务 + */ + async getTaskAuditJob(jobId: string): Promise | undefined> { + return await this.taskAuditQueue.getJob(jobId) + } + + /** + * 添加AI图片异步生成任务 + */ + async addAiImageAsyncJob(data: AiImageData, options?: JobsOptions) { + return await this.aiImageAsyncQueue.add('generate', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取AI图片异步生成任务 + */ + async getAiImageAsyncJob(jobId: string): Promise | undefined> { + return await this.aiImageAsyncQueue.getJob(jobId) + } + + /** + * 添加互动任务分发任务 + */ + async addEngagementTaskDistributionJob( + data: EngagementTaskDistributionData, + options?: JobsOptions, + ) { + return await this.engagementTaskDistributionQueue.add('distribute', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取互动任务分发任务 + */ + async getEngagementTaskDistributionJob( + jobId: string, + ): Promise | undefined> { + return await this.engagementTaskDistributionQueue.getJob(jobId) + } + + /** + * 添加评论回复任务 + */ + async addEngagementReplyToCommentJob(data: EngagementReplyToCommentData, options?: JobsOptions) { + return await this.engagementReplyToCommentQueue.add('reply', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取评论回复任务 + */ + async getEngagementReplyToCommentJob( + jobId: string, + ): Promise | undefined> { + return await this.engagementReplyToCommentQueue.getJob(jobId) + } + + /** + * 添加云空间配置任务 + */ + async addCloudspaceConfigureJob(data: CloudspaceConfigureData, options?: JobsOptions) { + return await this.cloudspaceConfigureQueue.add('configure', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取云空间配置任务 + */ + async getCloudspaceConfigureJob( + jobId: string, + ): Promise | undefined> { + return await this.cloudspaceConfigureQueue.getJob(jobId) + } + + /** + * 添加云空间过期处理任务 + */ + async addCloudspaceExpirationJob(data: CloudspaceExpirationData, options?: JobsOptions) { + return await this.cloudspaceExpirationQueue.add('expire', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取云空间过期处理任务 + */ + async getCloudspaceExpirationJob( + jobId: string, + ): Promise | undefined> { + return await this.cloudspaceExpirationQueue.getJob(jobId) + } + + /** + * 添加用户创建时推送任务 + */ + async addTaskUserCreatePushJob(data: TaskUserCreateData, options?: JobsOptions) { + return await this.taskUserCreatePushQueue.add('push', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取用户创建时推送任务 + */ + async getTaskUserCreatePushJob(jobId: string): Promise | undefined> { + return await this.taskUserCreatePushQueue.getJob(jobId) + } + + /** + * 添加用户画像上报任务 + */ + async addTaskUserPortraitReportJob(data: TaskUserPortraitData, options?: JobsOptions) { + return await this.taskUserPortraitReportQueue.add('report', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取用户画像上报任务 + */ + async getTaskUserPortraitReportJob( + jobId: string, + ): Promise | undefined> { + return await this.taskUserPortraitReportQueue.getJob(jobId) + } + + /** + * 添加频道账号画像上报任务 + */ + async addTaskAccountPortraitReportJob(data: TaskAccountPortraitData, options?: JobsOptions) { + return await this.taskAccountPortraitReportQueue.add('report', data, { + ...this.defaultOptions, + ...options, + }) + } + + /** + * 获取频道账号画像上报任务 + */ + async getTaskAccountPortraitReportJob( + jobId: string, + ): Promise | undefined> { + return await this.taskAccountPortraitReportQueue.getJob(jobId) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.json b/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.lib.json b/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-queue/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/README.md b/project/aitoearn-monorepo/libs/aitoearn-server-client/README.md new file mode 100644 index 000000000..9c3179890 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/README.md @@ -0,0 +1,7 @@ +# one-signal + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build one-signal` to build the library. diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/eslint.config.mjs b/project/aitoearn-monorepo/libs/aitoearn-server-client/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/package.json b/project/aitoearn-monorepo/libs/aitoearn-server-client/package.json new file mode 100644 index 000000000..bb2e94724 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/package.json @@ -0,0 +1,25 @@ +{ + "name": "@yikart/aitoearn-server-client", + "type": "commonjs", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@yikart/common": "workspace:*", + "@yikart/mongodb": "workspace:*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "axios": "^1.13.0", + "rxjs": "^7.8.0" + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/project.json b/project/aitoearn-monorepo/libs/aitoearn-server-client/project.json new file mode 100644 index 000000000..522744f11 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/project.json @@ -0,0 +1,42 @@ +{ + "name": "aitoearn-server-client", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/aitoearn-server-client/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/aitoearn-server-client", + "tsConfig": "libs/aitoearn-server-client/tsconfig.lib.json", + "packageJson": "libs/aitoearn-server-client/package.json", + "main": "libs/aitoearn-server-client/src/index.ts", + "assets": ["libs/aitoearn-server-client/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.config.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.config.ts new file mode 100644 index 000000000..d606b515e --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.config.ts @@ -0,0 +1,9 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +export const aitoearnServerClientConfigSchema = z.object({ + baseUrl: z.string(), + token: z.string(), +}) + +export class AitoearnServerClientConfig extends createZodDto(aitoearnServerClientConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.interface.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.module.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.module.ts new file mode 100644 index 000000000..83148a435 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.module.ts @@ -0,0 +1,33 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { AitoearnServerClientConfig } from './aitoearn-server-client.config' +import { AitoearnServerClientService } from './aitoearn-server-client.service' +import { AccountService, AiService, CloudSpaceService, ContentService, IncomeService, NotificationService, PublishingService, TaskService, UserService } from './clients' + +@Module({}) +export class AitoearnServerClientModule { + static forRoot(options: AitoearnServerClientConfig): DynamicModule { + return { + global: true, + module: AitoearnServerClientModule, + imports: [ + ], + providers: [ + { + provide: AitoearnServerClientConfig, + useValue: options, + }, + AccountService, + AiService, + CloudSpaceService, + ContentService, + IncomeService, + NotificationService, + PublishingService, + TaskService, + UserService, + AitoearnServerClientService, + ], + exports: [AitoearnServerClientService], + } + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.service.ts new file mode 100644 index 000000000..30a0d859d --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/aitoearn-server-client.service.ts @@ -0,0 +1,53 @@ +import { Injectable } from '@nestjs/common' +import { AccountService, AiService, CloudSpaceService, ContentService, IncomeService, NotificationService, PublishingService, TaskService, UserService } from './clients' + +@Injectable() +export class AitoearnServerClientService { + constructor( + private readonly aiService: AiService, + private readonly accountService: AccountService, + private readonly cloudSpaceService: CloudSpaceService, + private readonly contentService: ContentService, + private readonly incomeService: IncomeService, + private readonly notificationService: NotificationService, + private readonly publishingService: PublishingService, + private readonly taskService: TaskService, + private readonly userService: UserService, + ) {} + + get ai() { + return this.aiService + } + + get account() { + return this.accountService + } + + get content() { + return this.contentService + } + + get publishing() { + return this.publishingService + } + + get task() { + return this.taskService + } + + get cloudSpace() { + return this.cloudSpaceService + } + + get income() { + return this.incomeService + } + + get notification() { + return this.notificationService + } + + get user() { + return this.userService + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/account.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/account.service.ts new file mode 100644 index 000000000..b24d3b504 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/account.service.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { Account, UpdateAccountStatisticsData } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class AccountService extends BaseService { + /** 创建账户 */ + async createAccount( + data: Partial, + ) { + const url = `/internal/${data.userId}/socials/accounts` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + const res = await this.request( + url, + config, + ) + + return res + } + + // 更新账号信息 + async updateAccountInfo(accountId: string, data: Partial) { + const url = `/internal/${data.userId}/socials/accounts/${accountId}` + const config: AxiosRequestConfig = { + method: 'PATCH', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + /** + * 获取账户详情 + * @param accountId + * @returns + */ + async getAccountInfo(accountId: string) { + const url = `/internal/socials/accounts/${accountId}` + const config: AxiosRequestConfig = { + method: 'GET', + } + const res = await this.request( + url, + config, + ) + return res + } + + /** + * 更新用户账户统计信息 + * @param accountId + * @param data + * @returns + */ + async updateAccountStatistics(accountId: string, data: UpdateAccountStatisticsData) { + const url = `/internal/socials/accounts/${accountId}/statistics` + const config: AxiosRequestConfig = { + method: 'PATCH', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + // ====== Internal Query APIs (migrated from /accountInternal/*) ====== + async getAccountInfoInternal(id: string) { + const url = `/internal/account/info` + const config: AxiosRequestConfig = { + method: 'POST', + data: { id }, + } + return this.request(url, config) + } + + async getAccountListByIds(ids: string[]) { + const url = `/internal/account/list/ids` + const config: AxiosRequestConfig = { + method: 'POST', + data: { ids }, + } + return this.request(url, config) + } + + async getAccountListByTypes(types: string[], status?: number) { + const url = `/internal/account/list/types` + const config: AxiosRequestConfig = { + method: 'POST', + data: { types, status }, + } + return this.request(url, config) + } + + async getAccountListByParam(param: Record) { + const url = `/internal/account/list/param` + const config: AxiosRequestConfig = { + method: 'POST', + data: param, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/ai.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/ai.service.ts new file mode 100644 index 000000000..7489a5986 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/ai.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common' +import { UserType } from '@yikart/common' +import { AxiosRequestConfig } from 'axios' +import { ChatCompletionVo, ImageResponseVo, UserChatCompletionDto, UserImageGenerationDto } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class AiService extends BaseService { + async chatCompletion(data: UserChatCompletionDto) { + const url = `/internal/ai/chat/completion` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + async imageGenerate(data: UserImageGenerationDto) { + const url = `/internal/ai/image/generate` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + async getImageGenerationModels( + data: { + userId: string + userType: UserType + }, + ) { + const url = `/internal/ai/models/image/generation` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + const res = await this.request( + url, + config, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/base.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/base.service.ts new file mode 100644 index 000000000..db93a770a --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/base.service.ts @@ -0,0 +1,56 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AppException, COMMON_PROPAGATION_HEADERS, CommonResponse, propagationContext } from '@yikart/common' +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' +import { AitoearnServerClientConfig } from '../aitoearn-server-client.config' + +@Injectable() +export class BaseService { + protected readonly httpClient: AxiosInstance + private readonly logger = new Logger(BaseService.name) + constructor( + private readonly config: AitoearnServerClientConfig, + ) { + this.httpClient = axios.create({ + baseURL: this.config.baseUrl, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.token}`, + }, + }) + + this.httpClient.interceptors.request.use((request) => { + const store = propagationContext.getStore() + if (store == null) + return request + COMMON_PROPAGATION_HEADERS + .forEach((key) => { + const value = store.headers[key] + if (value) { + request.headers.set(key, value) + } + }) + return request + }) + + const resInterceptor = (response: AxiosResponse) => { + const res = response.data as CommonResponse + if (res.code !== 0) { + this.logger.error({ path: response.config.url, ...res }) + throw new AppException(res.code, res.message) + } + return response + } + + this.httpClient.interceptors.response.use(resInterceptor) + } + + async request( + url: string, + config: AxiosRequestConfig = {}, + ): Promise { + const response: AxiosResponse> = await this.httpClient(url, config) + + return response.data.data! + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/cloud-space.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/cloud-space.service.ts new file mode 100644 index 000000000..ee7891b05 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/cloud-space.service.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@nestjs/common' +import { AccountGroup, CloudSpace } from '@yikart/mongodb' +import { AxiosRequestConfig } from 'axios' +import { + CreateCloudSpaceDto, + RenewCloudSpaceDto, +} from '../interfaces' +import { BaseService } from './base.service' + +export interface CreateAccountGroupDto { + userId: string + name: string +} + +@Injectable() +export class CloudSpaceService extends BaseService { + async createCloudSpace(data: CreateCloudSpaceDto): Promise { + const url = `/internal/cloud-space/create` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } + + async renewCloudSpace(data: RenewCloudSpaceDto): Promise { + const url = `/internal/cloud-space/renew` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } + + async createAccountGroup(data: CreateAccountGroupDto): Promise { + const url = `/internal/cloud-space/create-account-group` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/content.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/content.service.ts new file mode 100644 index 000000000..2994e29e3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/content.service.ts @@ -0,0 +1,89 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { Material, MaterialGroup, MaterialTask, NewMaterialTask } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class ContentService extends BaseService { + async deleteMaterial(id: string) { + const url = `/internal/publishing/materials/${id}` + const config: AxiosRequestConfig = { + method: 'DELETE', + } + const res = await this.request( + url, + config, + ) + return res + } + + async getMaterialListByIds(ids: string[]) { + const url = `/internal/material/list/ids` + const config: AxiosRequestConfig = { + method: 'POST', + data: { ids }, + } + return this.request(url, config) + } + + async optimalByIds(ids: string[]) { + const url = `/internal/material/optimalByIds` + const config: AxiosRequestConfig = { + method: 'POST', + data: { ids }, + } + return this.request(url, config) + } + + async getGroupInfo(id: string) { + const url = `/internal/material/group/info` + const config: AxiosRequestConfig = { + method: 'POST', + data: { id }, + } + return this.request(url, config) + } + + async optimalInGroup(groupId: string) { + const url = `/internal/material/group/optimal` + const config: AxiosRequestConfig = { + method: 'POST', + data: { groupId }, + } + return this.request(url, config) + } + + async createMaterialTask(data: NewMaterialTask) { + const url = `/internal/content/material/createTask` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } + + async previewMaterialTask(id: string) { + const url = `/internal/content/material/preview/${id}` + const config: AxiosRequestConfig = { + method: 'GET', + } + return this.request(url, config) + } + + async startMaterialTask(id: string) { + const url = `/internal/content/material/start/${id}` + const config: AxiosRequestConfig = { + method: 'GET', + } + return this.request(url, config) + } + + async increaseMaterialUseCount(id: string) { + const url = `/internal/material/use/increase` + const config: AxiosRequestConfig = { + method: 'POST', + data: { id }, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/income.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/income.service.ts new file mode 100644 index 000000000..341d5b8a6 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/income.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { IncomeType } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class IncomeService extends BaseService { + async addIncome(data: { + userId: string + amount: number // 分 + type: IncomeType + relId?: string + desc?: string + metadata?: Record + }) { + const url = `/internal/income/add` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } + + async deductIncome(data: { + userId: string + amount: number + type: IncomeType + relId?: string + desc?: string + metadata?: Record + }) { + const url = `/internal/income/deduct` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/index.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/index.ts new file mode 100644 index 000000000..d08497824 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/index.ts @@ -0,0 +1,10 @@ +export * from './account.service' +export * from './ai.service' +export * from './base.service' +export * from './cloud-space.service' +export * from './content.service' +export * from './income.service' +export * from './notification.service' +export * from './publishing.service' +export * from './task.service' +export * from './user.service' diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/notification.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/notification.service.ts new file mode 100644 index 000000000..cfd5eb9c4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/notification.service.ts @@ -0,0 +1,16 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { NewNotification } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class NotificationService extends BaseService { + async createForUser(payload: NewNotification) { + const url = `/internal/notification/createForUser` + const config: AxiosRequestConfig = { + method: 'POST', + data: payload, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/publishing.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/publishing.service.ts new file mode 100644 index 000000000..bdb983418 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/publishing.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { PublishRecord, PublishStatus } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class PublishingService extends BaseService { + /** + * 创建账户 + * @param data + * @returns + */ + async createPublishRecord( + data: Partial, + ) { + const url = `/internal/publishing/records` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + async getPublishRecordInfo(recordId: string) { + const url = `/internal/publishing/records/${recordId}` + const config: AxiosRequestConfig = { + method: 'GET', + } + const res = await this.request( + url, + config, + ) + return res + } + + async getPublishRecordByDataId(dataId: string, uid: string) { + const url = `/internal/${uid}/publishing/records/${dataId}` + const config: AxiosRequestConfig = { + method: 'GET', + } + const res = await this.request( + url, + config, + ) + return res + } + + async completePublishTask(filter: { dataId: string, uid: string }, data: { + workLink?: string + dataOption?: any + }) { + const url = `/internal/${filter.uid}/publishing/records/${filter.dataId}` + const config: AxiosRequestConfig = { + method: 'PATCH', + data, + } + const res = await this.request( + url, + config, + ) + return res + } + + async updatePublishRecordStatus(id: string, status: PublishStatus, errorMsg?: string) { + const url = `/internal/publishing/records/${id}/status` + const config: AxiosRequestConfig = { + method: 'PATCH', + data: { + status, + errorMsg, + }, + } + const res = await this.request( + url, + config, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/task.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/task.service.ts new file mode 100644 index 000000000..91e63b2a6 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/task.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common' +import { AxiosRequestConfig } from 'axios' +import { Task } from '../interfaces' +import { BaseService } from './base.service' + +@Injectable() +export class TaskService extends BaseService { + /** + * 获取任务信息 + * @param taskId 任务id + * @returns 用户信息 + */ + async getTask(taskId: string) { + const url = `/internal/tasks/${taskId}` + const config: AxiosRequestConfig = { + method: 'GET', + } + const res = await this.request( + url, + config, + ) + return res + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/user.service.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/user.service.ts new file mode 100644 index 000000000..bb0dbdf69 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/clients/user.service.ts @@ -0,0 +1,59 @@ +import { Injectable } from '@nestjs/common' +import { VipStatus } from '@yikart/mongodb' +import { AxiosRequestConfig } from 'axios' +import { BaseService } from './base.service' + +export interface AddPointsDto { + userId: string + amount: number + type: string + description?: string + metadata?: Record +} + +export interface DeductPointsDto { + userId: string + amount: number + type: string + description?: string + metadata?: Record +} + +@Injectable() +export class UserService extends BaseService { + async getUserInfo(userId: string): Promise { + const url = `/internal/user/info` + const config: AxiosRequestConfig = { + method: 'POST', + data: { id: userId }, + } + return this.request(url, config) + } + + async setVip(userId: string, status: VipStatus) { + const url = `/internal/user/vip/set` + const config: AxiosRequestConfig = { + method: 'POST', + data: { userId, status }, + } + return this.request(url, config) + } + + async addPoints(data: AddPointsDto) { + const url = `/internal/user/points/add` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } + + async deductPoints(data: DeductPointsDto) { + const url = `/internal/user/points/deduct` + const config: AxiosRequestConfig = { + method: 'POST', + data, + } + return this.request(url, config) + } +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/index.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/index.ts new file mode 100644 index 000000000..b37893172 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/index.ts @@ -0,0 +1,4 @@ +export * from './aitoearn-server-client.config' +export * from './aitoearn-server-client.module' +export * from './aitoearn-server-client.service' +export * from './interfaces' diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/account.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/account.interface.ts new file mode 100644 index 000000000..09fbf2a85 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/account.interface.ts @@ -0,0 +1,113 @@ +// 平台类型 +export enum AccountType { + Douyin = 'douyin', // 抖音 + Xhs = 'xhs', // 小红书 + WxSph = 'wxSph', // 微信视频号 + KWAI = 'KWAI', // 快手 + YOUTUBE = 'youtube', // youtube + WxGzh = 'wxGzh', // 微信公众号 + BILIBILI = 'bilibili', // B站 + TWITTER = 'twitter', // twitter + TIKTOK = 'tiktok', // tiktok + FACEBOOK = 'facebook', // facebook + INSTAGRAM = 'instagram', // instagram + THREADS = 'threads', // threads + PINTEREST = 'pinterest', + LINKEDIN = 'linkedin', // linkedin +} + +export enum AccountStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +export interface Account { + id: string + userId: string + type: AccountType + uid: string + account: string + loginCookie: string + access_token?: string + refresh_token?: string + loginTime?: Date + avatar?: string + nickname: string + status: AccountStatus // 登录状态,用于判断是否失效 + channelId?: string +} + +export class NewAccount implements Partial { + constructor(data: { + userId: string + type: AccountType + uid: string + account: string + loginCookie?: string + access_token?: string + refresh_token?: string + token?: string + avatar?: string + nickname: string + lastStatsTime?: Date + loginTime?: Date + channelId?: string + status?: AccountStatus + groupId?: string + }) { + Object.assign(this, data) + } +} + +export interface UpdateAccountStatisticsData { + workCount?: number + fansCount?: number + readCount?: number + likeCount?: number + collectCount?: number + commentCount?: number + income?: number +} + +export enum PublishType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', +} + +export enum PublishStatus { + FAILED = -1, // 发布失败 + WaitingForPublish = 0, // 未发布 + PUBLISHED = 1, // 已发布 + PUBLISHING = 2, // 发布中 +} + +export interface PublishRecord { + _id: string + id: string + userId: string + flowId?: string // 前端传入的流水ID + userTaskId?: string // 用户任务ID + taskMaterialId?: string // 任务素材ID + taskId?: string // 任务ID + type: PublishType + title?: string + desc?: string // 主要内容 + accountId: string + topics: string[] + accountType: AccountType + uid: string + videoUrl?: string + coverUrl?: string + imgUrlList?: string[] + publishTime: Date + status: PublishStatus + errorMsg?: string + queueId?: string + inQueue: boolean + option?: any + dataId: string // 微信公众号-publish_id + workLink?: string // 作品链接 + dataOption?: Record + createdAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/ai.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/ai.interface.ts new file mode 100644 index 000000000..c04d78725 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/ai.interface.ts @@ -0,0 +1,875 @@ +import { Pagination, UserType } from '@yikart/common' + +// Models Config interfaces +export interface ModelsConfigVo { + chat: Array + image: { + generation: Array + edit: Array + } + video: { + generation: Array + } +} + +export interface ModelsConfigDto { + chat: Array + image: { + generation: Array + edit: Array + } + video: { + generation: Array + } +} + +export enum AiLogType { + Chat = 'chat', + Image = 'image', + Card = 'card', + Video = 'video', +} + +export enum AiLogStatus { + Generating = 'generating', + Success = 'success', + Failed = 'failed', +} + +export enum AiLogChannel { + NewApi = 'new-api', + Md2Card = 'md2card', + FireflyCard = 'fireflyCard', + Kling = 'kling', + Volcengine = 'volcengine', + Dashscope = 'dashscope', + Sora2 = 'sora2', +} + +// Fireflycard 模板类型枚举 +export enum FireflycardTempTypes { + A = 'tempA', + B = 'tempB', + C = 'tempC', + Jin = 'tempJin', + Memo = 'tempMemo', + Easy = 'tempEasy', + BlackSun = 'tempBlackSun', + E = 'tempE', + Write = 'tempWrite', + Code = 'code', + D = 'tempD', +} + +// Image DTO 接口 +export interface ImageGenerationDto { + prompt: string + model?: string + n?: number + quality?: string + response_format?: string + size?: string + style?: string + user?: string +} + +export interface ImageEditDto { + model?: string + image: string | string[] + prompt: string + mask?: string + n?: number + size?: string + response_format?: string + user?: string +} + +export interface Md2CardDto { + markdown: string + theme?: string + themeMode?: string + width?: number + height?: number + splitMode?: string + mdxMode?: boolean + overHiddenMode?: boolean +} + +export interface FireflyCardDto { + content: string + temp: FireflycardTempTypes + title?: string + style?: { + align?: string + backgroundName?: string + backShadow?: string + font?: string + width?: number + ratio?: string + height?: number + fontScale?: number + padding?: string + borderRadius?: string + color?: string + opacity?: number + blur?: number + backgroundAngle?: string + lineHeights?: { + content?: string + } + letterSpacings?: { + content?: string + } + } + switchConfig?: { + showIcon?: boolean + showDate?: boolean + showTitle?: boolean + showContent?: boolean + showAuthor?: boolean + showTextCount?: boolean + showQRCode?: boolean + showPageNum?: boolean + showWatermark?: boolean + } +} + +// User Image DTO 接口 +export interface UserImageGenerationDto extends ImageGenerationDto { + userId: string + userType: UserType +} + +export interface UserImageEditDto extends ImageEditDto { + userId: string + userType: UserType +} + +export interface UserMd2CardDto extends Md2CardDto { + userId: string + userType: UserType +} + +export interface UserFireflyCardDto extends FireflyCardDto { + userId: string + userType: UserType +} + +export interface ImageResponseVo { + created: number + list: Array<{ + url?: string + b64_json?: string + revised_prompt?: string + }> + usage?: { + input_tokens?: number + output_tokens?: number + total_tokens?: number + } + background?: string + output_format?: string + quality?: string + size?: string +} + +export interface Md2CardResponseVo { + images: { + url: string + fileName: string + }[] +} + +export interface FireflycardResponseVo { + image: string +} + +export interface ImageGenerationModelParamsVo { + name: string + description: string + summary?: string + logo?: string + tags: string[] + mainTag?: string + sizes: string[] + qualities: string[] + styles: string[] + pricing: string + discount?: string + originPrice?: string +} + +export interface ImageEditModelParamsVo { + name: string + description: string + summary?: string + logo?: string + tags: string[] + mainTag?: string + sizes: string[] + pricing: string + discount?: string + originPrice?: string + maxInputImages: number +} + +// 画面纵横比枚举 +export enum AspectRatio { + Square = '1:1', + Portrait = '9:16', + Landscape = '16:9', +} + +// Kling 任务状态枚举 +export enum KlingTaskStatus { + Submitted = 'submitted', + Processing = 'processing', + Succeed = 'succeed', + Failed = 'failed', +} + +// Kling 模式枚举 +export enum KlingMode { + Std = 'std', + Pro = 'pro', +} + +// Volcengine 任务状态枚举 +export enum VolcengineTaskStatus { + Queued = 'queued', + Running = 'running', + Cancelled = 'cancelled', + Succeeded = 'succeeded', + Failed = 'failed', +} + +// Volcengine 内容类型枚举 +export enum VolcengineContentType { + Text = 'text', + ImageUrl = 'image_url', +} + +// Volcengine 图片角色枚举 +export enum VolcengineImageRole { + FirstFrame = 'first_frame', + LastFrame = 'last_frame', + ReferenceImage = 'reference_image', +} + +// Video DTO 接口 +export interface VideoGenerationRequestDto { + model: string + prompt: string + image?: string | string[] + image_tail?: string + mode?: string + size?: string + duration?: number + metadata?: Record +} + +export interface VideoTaskQueryDto { + taskId: string +} + +export interface KlingText2VideoRequestDto { + userId: string + userType: UserType + model_name: string + prompt: string + negative_prompt?: string + cfg_scale?: number + mode?: KlingMode + duration?: '5' | '10' + external_task_id?: string +} +// 运镜配置接口 +export interface CameraControlConfig { + /** 水平运镜,控制摄像机在水平方向上的移动量(沿x轴平移) */ + horizontal?: number + /** 垂直运镜,控制摄像机在垂直方向上的移动量(沿y轴平移) */ + vertical?: number + /** 水平摇镜,控制摄像机在水平面上的旋转量(绕y轴旋转) */ + pan?: number + /** 垂直摇镜,控制摄像机在垂直面上的旋转量(沿x轴旋转) */ + tilt?: number + /** 旋转运镜,控制摄像机的滚动量(绕z轴旋转) */ + roll?: number + /** 变焦,控制摄像机的焦距变化,影响视野的远近 */ + zoom?: number +} + +// 运镜类型枚举 +export enum CameraControlType { + /** 简单运镜 */ + Simple = 'simple', + /** 镜头下压并后退 */ + DownBack = 'down_back', + /** 镜头前进并上仰 */ + ForwardUp = 'forward_up', + /** 先右旋转后前进 */ + RightTurnForward = 'right_turn_forward', + /** 先左旋并前进 */ + LeftTurnForward = 'left_turn_forward', +} + +// 运镜控制接口 +export interface CameraControl { + /** 预定义的运镜类型 */ + type?: CameraControlType + /** 运镜配置 */ + config?: CameraControlConfig +} +// 动态笔刷轨迹点接口 +export interface TrajectoryPoint { + /** 轨迹点横坐标 */ + x: number + /** 轨迹点纵坐标 */ + y: number +} + +// 动态笔刷配置接口 +export interface DynamicMask { + /** 动态笔刷涂抹区域 */ + mask: string + /** 运动轨迹坐标序列 */ + trajectories: TrajectoryPoint[] +} + +export interface KlingImage2VideoRequestDto { + userId: string + userType: UserType + model_name: string + image?: string + image_tail?: string + prompt?: string + negative_prompt?: string + cfg_scale?: number + mode?: KlingMode + static_mask?: string + dynamic_masks?: DynamicMask[] + camera_control?: CameraControl + duration?: '5' | '10' + aspect_ratio?: AspectRatio +} + +export interface ImageListItem { + /** 图片URL */ + image: string +} +export interface KlingMultiImage2VideoRequestDto { + userId: string + userType: UserType + model_name: string + image_list: ImageListItem[] + prompt: string + negative_prompt?: string + mode?: KlingMode + duration?: '5' | '10' + aspect_ratio?: AspectRatio +} + +export interface VolcengineGenerationRequestDto { + userId: string + userType: UserType + model: string + content: Array< + | { + type: VolcengineContentType.Text + text: string + } + | { + type: VolcengineContentType.ImageUrl + image_url: { + url: string + } + role?: VolcengineImageRole + } + > + return_last_frame?: boolean +} + +export interface KlingCallbackDto { + task_id: string + task_status: KlingTaskStatus + task_status_msg: string + task_info: { + parent_video?: { + id: string + url: string + duration: string + } + external_task_id?: string + } + created_at: number + updated_at: number + task_result?: { + images?: Array<{ + index: number + url: string + }> + videos?: Array<{ + id: string + url: string + duration: string + }> + } +} + +// User Video DTO 接口 +export interface UserVideoGenerationRequestDto extends VideoGenerationRequestDto { + userId: string + userType: UserType +} + +export interface UserVideoTaskQueryDto extends VideoTaskQueryDto { + userId: string + userType: UserType +} + +export interface UserListVideoTasksQueryDto extends Pagination { + userId: string + userType: UserType +} + +export interface KlingTaskQueryDto { + userId: string + userType: UserType + taskId: string +} + +export interface VolcengineTaskQueryDto { + userId: string + userType: UserType + taskId: string +} + +// ==================== Dashscope API DTO ==================== + +// Dashscope文生视频请求DTO +export interface DashscopeText2VideoRequestDto { + userId: string + userType: UserType + model: string + input: { + prompt: string + negative_prompt?: string + } + parameters?: { + size?: string + duration?: number + prompt_extend?: boolean + } +} + +// Dashscope图生视频请求DTO +export interface DashscopeImage2VideoRequestDto { + userId: string + userType: UserType + model: string + input: { + image_url: string + prompt?: string + negative_prompt?: string + } + parameters?: { + resolution?: string + prompt_extend?: boolean + } +} + +// Dashscope首尾帧生视频请求DTO +export interface DashscopeKeyFrame2VideoRequestDto { + userId: string + userType: UserType + model: string + input: { + first_frame_url: string + last_frame_url?: string + prompt?: string + negative_prompt?: string + template?: string + } + parameters?: { + resolution?: string + duration?: number + prompt_extend?: boolean + } +} + +// Dashscope任务查询DTO +export interface DashscopeTaskQueryDto { + userId: string + userType: UserType + taskId: string +} + +// ==================== Dashscope API Response VO ==================== +export enum DashscopeTaskStatus { + Pending = 'PENDING', + Running = 'RUNNING', + Succeeded = 'SUCCEEDED', + Failed = 'FAILED', + Canceled = 'CANCELED', + Unknown = 'UNKNOWN', +} + +// Dashscope视频生成响应VO +export interface DashscopeVideoGenerationResponseVo { + task_id: string + task_status?: DashscopeTaskStatus +} + +// Dashscope任务状态响应VO +export interface DashscopeTaskStatusResponseVo { + status_code: number + request_id: string + code: string | null + message: string + output: { + task_id: string + task_status: DashscopeTaskStatus + video_url?: string + submit_time?: string + scheduled_time?: string + end_time?: string + orig_prompt?: string + actual_prompt?: string + } + usage?: { + video_count: number + video_duration: number + video_ratio: string + } | null +} + +// Video VO 接口 +export interface KlingVideoGenerationResponseVo { + task_id: string + task_status?: string +} + +export interface VolcengineVideoGenerationResponseVo { + id: string +} + +export interface VideoGenerationResponseVo { + task_id: string + status: string +} + +export interface KlingTaskStatusResponseVo { + task_id: string + task_status: string + task_status_msg: string + task_info?: { + parent_video?: { + id: string + url: string + duration: string + } + external_task_id?: string + } + task_result?: { + images?: Array<{ + index: number + url: string + }> + videos?: Array<{ + id: string + url: string + duration: string + }> + } + created_at: number + updated_at: number +} + +export interface VolcengineTaskStatusResponseVo { + id: string + model: string + status: string + error: { + message: string + code: string + } | null + created_at: number + updated_at: number + content?: { + video_url?: string + last_frame_url?: string + } + seed?: number + resolution?: string + ratio?: string + duration?: number + framespersecond?: number + usage?: { + completion_tokens?: number + total_tokens?: number + } +} + +export enum VideoSize { + Large = 'large', + Small = 'small', +} + +// Sora2 接口定义 +export enum Sora2TaskStatus { + Pending = 'pending', + Running = 'running', + Cancelled = 'cancelled', + Completed = 'completed', + Failed = 'failed', +} + +export enum VideoOrientation { + Portrait = 'portrait', + Landscape = 'landscape', +} + +export interface Sora2GenerationRequestDto { + userId: string + userType: UserType + model: string + images?: string[] + orientation: VideoOrientation + prompt: string + size: VideoSize + duration: 10 | 15 +} + +export interface Sora2TaskQueryDto { + userId: string + userType: UserType + taskId: string +} + +export interface Sora2VideoGenerationResponseVo { + id: string + status: Sora2TaskStatus +} + +export interface Sora2TaskStatusResponseVo { + id: string + status: Sora2TaskStatus + video_url?: string + thumbnail_url?: string + status_update_time: number + finish_reason?: string +} + +export interface VideoTaskStatusResponseVo { + task_id: string + action: string + status: string + fail_reason?: string + submit_time: number + start_time: number + finish_time: number + progress: string + data: any +} + +export interface VideoGenerationModelParamsVo { + name: string + description: string + summary?: string + logo?: string + tags: string[] + mainTag?: string + modes: ('text2video' | 'image2video' | 'flf2video' | 'lf2video' | 'multi-image2video')[] + channel: AiLogChannel + resolutions: string[] + durations: number[] + supportedParameters: string[] + defaults?: { + resolution?: string + aspectRatio?: string + mode?: string + duration?: number + } + pricing: Array<{ + resolution?: string + aspectRatio?: string + mode?: string + duration?: number + price: number + discount?: string + originPrice?: number + }> +} + +// ==================== Query DTO 接口 ==================== + +// 聊天模型查询DTO +export interface ChatModelsQueryDto { + userId?: string + userType?: UserType +} + +// 图片生成模型查询DTO +export interface ImageGenerationModelsQueryDto { + userId?: string + userType?: UserType +} + +// 图片编辑模型查询DTO +export interface ImageEditModelsQueryDto { + userId?: string + userType?: UserType +} + +// 视频生成模型查询DTO +export interface VideoGenerationModelsQueryDto { + userId?: string + userType?: UserType +} + +// ==================== Chat 模块接口 ==================== + +// 消息内容类型 +export interface MessageContentText { + type: 'text' + text: string +} + +export interface MessageContentImageUrl { + type: 'image_url' + image_url: { + url: string + detail?: 'auto' | 'low' | 'high' + } +} + +export interface MessageContentComplex { + type?: string + [key: string]: any +} + +export type MessageContent = string | (MessageContentText | MessageContentImageUrl | MessageContentComplex)[] + +// 聊天消息接口 +export interface ChatMessage { + role: string + content: MessageContent +} + +// Chat DTO 接口 +export interface ChatCompletionDto { + messages: ChatMessage[] + model: string + temperature?: number + maxTokens?: number + maxCompletionTokens?: number + modalities?: ('text' | 'audio' | 'image' | 'video')[] + topP?: number + modelKwargs?: Record +} + +export interface UserChatCompletionDto extends ChatCompletionDto { + userId: string + userType: UserType +} + +// Token 使用情况接口 +export interface ModalitiesTokenDetails { + text?: number + image?: number + audio?: number + video?: number + document?: number +} + +export interface InputTokenDetails extends ModalitiesTokenDetails { + cache_read?: number + cache_creation?: number +} + +export interface OutputTokenDetails extends ModalitiesTokenDetails { + reasoning?: number +} + +export interface TokenUsage { + points?: number + input_tokens?: number + output_tokens?: number + total_tokens?: number + input_token_details?: InputTokenDetails + output_token_details?: OutputTokenDetails +} + +// Chat VO 接口 +export interface ChatCompletionVo { + content: MessageContent + model?: string + usage?: TokenUsage +} + +export interface ChatModelConfigVo { + name: string + description: string + summary?: string + logo?: string + tags: string[] + mainTag?: string + inputModalities: ('text' | 'image' | 'video' | 'audio')[] + outputModalities: ('text' | 'image' | 'video' | 'audio')[] + pricing: { + discount?: string + prompt: string + originPrompt?: string + completion: string + originCompletion?: string + image?: string + originImage?: string + audio?: string + originAudio?: string + } | { + price: string + discount?: string + originPrice?: string + } +} + +// ==================== Logs 模块接口 ==================== + +// Logs DTO 接口 +export interface LogListQueryDto { + userId?: string + userType?: UserType + page?: number + pageSize?: number +} + +export interface LogDetailQueryDto { + id: string + userId?: string + userType?: UserType +} + +// Logs VO 接口 +export interface LogVo { + id: string + userId: string + userType: UserType + taskId?: string + type: AiLogType + model: string + channel: AiLogChannel + action?: string + status: AiLogStatus + startedAt?: string + duration?: number + points: number + createdAt: string + updatedAt: string +} + +// Logs VO 接口 +export interface LogDetailVo extends LogVo { + errorMessage?: string + request?: Record + response?: Record +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/cloud-space.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/cloud-space.interface.ts new file mode 100644 index 000000000..172895ecd --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/cloud-space.interface.ts @@ -0,0 +1,116 @@ +import { CloudSpaceRegion, CloudSpaceStatus } from '@yikart/mongodb' + +export interface CloudSpace { + id: string + userId: string + instanceId: string + accountGroupId: string + region: CloudSpaceRegion + status: CloudSpaceStatus + ip: string + password?: string + expiredAt: string + createdAt: string + updatedAt: string +} + +export interface CreateCloudSpaceDto { + userId: string + accountGroupId: string + region: CloudSpaceRegion + profileName?: string + month?: number +} + +export interface CreateAccountGroupDto { + userId: string + name: string +} + +export interface ListCloudSpacesDto { + userId?: string + region?: CloudSpaceRegion + status?: CloudSpaceStatus + page?: number + pageSize?: number +} + +export interface ListCloudSpacesByUserIdDto { + userId: string + region?: CloudSpaceRegion + status?: CloudSpaceStatus +} + +export interface GetCloudSpaceStatusDto { + cloudSpaceId: string +} + +export interface DeleteCloudSpaceDto { + cloudSpaceId: string +} + +export interface RenewCloudSpaceDto { + cloudSpaceId: string + month: number +} + +export interface RetryCloudSpaceDto { + cloudSpaceId: string +} + +// 浏览器配置文件相关的数据类型定义 +export interface BrowserProfile { + id: string + accountId: string + profileId: string + cloudSpaceId?: string + config: Record + createdAt: string + updatedAt: string +} + +export interface ListBrowserProfilesDto { + accountId?: string + profileId?: string + cloudSpaceId?: string + page?: number + pageSize?: number +} + +// MultiLogin 账号相关的数据类型定义 +export interface MultiloginAccount { + id: string + email: string + password: string + maxProfiles: number + currentProfiles: number + token?: string + createdAt: string + updatedAt: string +} + +export interface CreateMultiloginAccountDto { + email: string + password: string + maxProfiles?: number +} + +export interface UpdateMultiloginAccountDto { + id: string + email?: string + password?: string + maxProfiles?: number +} + +export interface ListMultiloginAccountsDto { + page?: number + pageSize?: number + email?: string + minMaxProfiles?: number + maxMaxProfiles?: number + hasAvailableSlots?: boolean +} + +export interface IdDto { + id: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/content.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/content.interface.ts new file mode 100644 index 000000000..98fb8dab9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/content.interface.ts @@ -0,0 +1,194 @@ +export enum PubStatus { + UNPUBLISH = 0, // 未发布/草稿 + RELEASED = 1, // 已发布 + FAIL = 2, // 发布失败 + PartSuccess = 3, // 部分成功 +} + +export enum PubType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 +} + +export interface PubRecord { + id: string + userId: string + accountId: string + commonCoverPath?: string + coverPath?: string + desc: string + publishTime?: Date + status: PubStatus + timingTime?: Date + title: string + type: PubType + videoPath?: string +} + +export enum MaterialType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 +} + +export enum MaterialStatus { + WAIT = 0, + SUCCESS = 1, + FAIL = -1, +} + +export interface Material { + id: string + userId: string + groupId?: string // 所属组ID + type: MaterialType + coverUrl?: string + mediaList: MaterialMedia[] + title: string + desc: string + status: MaterialStatus + option: Record + createAt: Date + updatedAt: Date +} + +export interface MaterialMedia { + url: string + type: MediaType + content?: string +} + +export interface NewMaterial { + groupId: string // 所属组ID + coverUrl?: string + mediaList: MaterialMedia[] + title: string + desc?: string + location?: number[] + option?: Record +} + +export interface NewMaterialTask { + groupId: string + num: number + aiModelTag: string + prompt: string + title?: string + desc?: string + location?: number[] + mediaGroups: string[] + coverGroup: string + option?: Record +} + +export interface MediaUrlInfo { + url: string + num: number + type: MediaType +} + +export enum MaterialTaskStatus { + WAIT = 0, + RUNNING = 1, + SUCCESS = 2, + FAIL = -1, +} + +export interface MaterialTask { + id: string + userId: string + userType?: string + groupId: string // 所属组ID + type: MaterialType + aiModelTag: string + prompt: string // 提示词 + coverGroup?: string + mediaGroups: string[] + option?: Record // 高级设置 + title?: string + desc?: string + location?: number[] + coverUrl?: string + mediaList: MaterialMedia[] + coverUrlList: MediaUrlInfo[] // 封面数组 + mediaUrlMap: MediaUrlInfo[][] // 媒体的二维数组 + reNum: number + max?: number + language?: string + status: MaterialTaskStatus +} + +export interface UpMaterial { + title?: string + desc?: string + option?: Record +} + +export interface MaterialFilter { + readonly userId: string + readonly title?: string + readonly groupId?: string +} + +export interface MaterialGroup { + id: string + userId: string + userType?: string + title: string + desc?: string + createAt: Date + updatedAt: Date +} + +export enum MediaType { + VIDEO = 'video', // 视频 + IMG = 'img', // 图片 +} + +export interface Media { + id: string + userId: string + userType?: string + groupId?: string // 所属组ID + materialId?: string // 所属素材ID + type: MaterialType + url: string + title?: string + desc?: string + createAt: Date + updatedAt: Date +} + +export interface NewMedia { + userId: string + userType?: string + groupId?: string // 所属组ID + materialId?: string // 所属素材ID + type: MediaType + url: string + thumbUrl?: string + title?: string + desc?: string +} + +export interface MediaGroup { + _id: string + id: string + userId: string + title: string + desc?: string + createAt: Date + updatedAt: Date + mediaList?: { list: Media[], total: number } +} +export interface NewMaterialGroup { + type: MaterialType + userId: string + userType?: string + name: string + readonly desc?: string +} + +export interface UpdateMaterialGroup { + name: string + readonly desc?: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/income.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/income.interface.ts new file mode 100644 index 000000000..e9da00ced --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/income.interface.ts @@ -0,0 +1,25 @@ +export enum IncomeType { + TASK = 'task', // 任务 + TASK_BACK = 'task_back', // 任务回退 + REWARD_BACK = 'reward_back', // 奖励回退 +} + +export enum UserStatus { + STOP = 0, + OPEN = 1, + DELETE = -1, +} + +export interface User { + id: string + name: string + mail: string + avatar?: string + phone?: string + status: UserStatus + createdAt: Date + updatedAt: Date + score: number // 积分 + income: number // 收入(分) + totalIncome: number +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/index.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/index.ts new file mode 100644 index 000000000..fd2565bbf --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/index.ts @@ -0,0 +1,7 @@ +export * from './account.interface' +export * from './ai.interface' +export * from './cloud-space.interface' +export * from './content.interface' +export * from './income.interface' +export * from './notification.interface' +export * from './task.interface' diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/notification.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/notification.interface.ts new file mode 100644 index 000000000..e6df6db16 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/notification.interface.ts @@ -0,0 +1,17 @@ +// 通知状态枚举 +export enum NotificationStatus { + Unread = 'unread', + Read = 'read', +} +// 通知类型枚举 +export enum NotificationType { + TaskReminder = 'task_reminder', // 任务提醒 + TaskPunish = 'task_punish', // 任务处罚 +} +export interface NewNotification { + userId: string + title: string + content: string + type: NotificationType + relatedId: string +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/task.interface.ts b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/task.interface.ts new file mode 100644 index 000000000..4e38659df --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/src/interfaces/task.interface.ts @@ -0,0 +1,67 @@ +import { AccountType } from '../interfaces' + +export enum UserTaskStatus { + DOING = 'doing', // 进行中 + PENDING = 'pending', // 待提现奖励 + APPROVED = 'approved', // 已通过(完成) + REJECTED = 'rejected', // 已拒绝 + CANCELLED = 'cancelled', // 已取消 + DEL = 'del', // 已删除或回退 +} + +export interface UserTask { + _id: string + id: string + userId: string + taskId: string + opportunityId?: string // 派发记录ID + accountId: string + accountType: AccountType + uid: string + status: UserTaskStatus + keepTime: number // 保持时间(秒) + submissionUrl?: string // 提交的视频、文章或截图URL + submissionTime?: Date // 提交时间 + completionTime?: Date // 完成时间 + rejectionReason?: string // 拒绝原因 + metadata?: Record // 额外信息,如审核反馈等 + isFirstTimeSubmission: boolean // 是否首次提交,用于确定是否给予首次奖励 + verifierUserId?: string // 核查人员ID + verificationNote?: string // 人工核查备注 + reward: number // 奖励金额 + rewardTime?: Date // 奖励发放时间 + taskMaterialId?: string // 任务的素材ID + screenshotUrls?: string[] // 任务完成截图 + createdAt: Date + updatedAt: Date +} + +export enum TaskType { + VIDEO = 'video', + ARTICLE = 'article', + PROMOTION = 'promotion', + INTERACTION = 'interaction', +} + +export enum TaskStatus { + ACTIVE = 'active', + CANCELLED = 'cancelled', + DEL = 'del', +} +export interface Task { + _id: string + id: string + title: string + description: string + type: TaskType + maxRecruits: number + currentRecruits: number + deadline: Date + reward: number + status: TaskStatus + accountTypes: AccountType[] + materialIds: string[] + autoDeleteMaterial?: boolean + createdAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.json b/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.lib.json b/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aitoearn-server-client/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/ali-green/README.md b/project/aitoearn-monorepo/libs/ali-green/README.md new file mode 100644 index 000000000..869ee5c66 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/README.md @@ -0,0 +1,7 @@ +# mail + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build ali-green` to build the library. diff --git a/project/aitoearn-monorepo/libs/ali-green/eslint.config.mjs b/project/aitoearn-monorepo/libs/ali-green/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/ali-green/package.json b/project/aitoearn-monorepo/libs/ali-green/package.json new file mode 100644 index 000000000..fb0c1348c --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/package.json @@ -0,0 +1,27 @@ +{ + "name": "@yikart/ali-green", + "type": "commonjs", + "version": "0.0.2", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@alicloud/credentials": "^2.4.4", + "@alicloud/green20220302": "^2.22.1", + "@alicloud/openapi-client": "^0.4.15", + "@alicloud/tea-util": "^1.4.10", + "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/common": "^11.0.0", + "@yikart/common": "*", + "zod": "^4.0.0" + }, + "dependencies": { + "tslib": "^2.3.0" + } +} diff --git a/project/aitoearn-monorepo/libs/ali-green/project.json b/project/aitoearn-monorepo/libs/ali-green/project.json new file mode 100644 index 000000000..7a6acd63c --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/project.json @@ -0,0 +1,42 @@ +{ + "name": "ali-green", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ali-green/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/ali-green", + "tsConfig": "libs/ali-green/tsconfig.lib.json", + "packageJson": "libs/ali-green/package.json", + "main": "libs/ali-green/src/index.ts", + "assets": ["libs/ali-green/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.constants.ts b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.constants.ts new file mode 100644 index 000000000..4954e2ce8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.constants.ts @@ -0,0 +1 @@ +export const ALI_GREEN_CLIENT = 'ALI_GREEN_CLIENT' diff --git a/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.module.ts b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.module.ts new file mode 100644 index 000000000..b0060cda8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.module.ts @@ -0,0 +1,39 @@ +import Credential, { Config } from '@alicloud/credentials' +import Green20220302 from '@alicloud/green20220302' +import * as $OpenApi from '@alicloud/openapi-client' +import { DynamicModule, Module } from '@nestjs/common' +import { ALI_GREEN_CLIENT } from './ali-green-api.constants' +import { AliGreenApiService } from './ali-green-api.service' + +@Module({}) +export class AliGreenApiModule { + static forRoot(config: { + accessKeyId: string + accessKeySecret: string + endpoint: string + }): DynamicModule { + return { + module: AliGreenApiModule, + providers: [ + { + provide: ALI_GREEN_CLIENT, + useFactory: () => { + const { accessKeyId, accessKeySecret, endpoint } = config + const credentialsConfig = new Config({ + type: 'access_key', // 凭证类型。 + accessKeyId, + accessKeySecret, + }) + const credential = new Credential(credentialsConfig) + const ali_config = new $OpenApi.Config({ + credential, + }) + ali_config.endpoint = endpoint + return new Green20220302(ali_config) + }, + }, + ], + exports: [ALI_GREEN_CLIENT, AliGreenApiService], + } + } +} diff --git a/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.service.ts b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.service.ts new file mode 100644 index 000000000..b36f2f6c4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/src/ali-green-api.service.ts @@ -0,0 +1,67 @@ +import { randomBytes } from 'node:crypto' +import Green20220302, * as $Green20220302 from '@alicloud/green20220302' +import * as $Util from '@alicloud/tea-util' +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ALI_GREEN_CLIENT } from './ali-green-api.constants' + +@Injectable() +export class AliGreenApiService { + private readonly logger = new Logger(AliGreenApiService.name) + constructor( + @Inject(ALI_GREEN_CLIENT) private readonly client: Green20220302, + ) {} + + async textGreen( + content: string, + ) { + const textModerationRequest = new $Green20220302.TextModerationPlusRequest({ service: 'comment_detection', serviceParameters: JSON.stringify({ content }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .textModerationWithOptions(textModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, content) + return error.message + }) + } + + async imgGreen( + imageUrl: string, + ) { + const dataId = randomBytes(4).toString('hex').slice(0, 8) + const serviceParameters = JSON.stringify({ dataId, imageUrl }) + const imageModerationRequest = new $Green20220302.ImageModerationRequest({ service: 'baselineCheck', serviceParameters }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .imageModerationWithOptions(imageModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, dataId, imageUrl) + return error.message + }) + } + + async videoGreen( + url: string, + ) { + const videoModerationRequest = new $Green20220302.VideoModerationRequest({ service: 'videoDetection', serviceParameters: JSON.stringify({ url }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .videoModerationWithOptions(videoModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, url) + return error.message + }) + } + + async getVideoResult( + taskId: string, + ) { + const videoModerationRequest = new $Green20220302.VideoModerationResultRequest({ service: 'videoDetection', serviceParameters: JSON.stringify({ taskId }) }) + const runtime = new $Util.RuntimeOptions({}) + return this.client + .videoModerationResultWithOptions(videoModerationRequest, runtime) + .catch((error) => { + this.logger.error(error.message, taskId) + return error.message + }) + } +} diff --git a/project/aitoearn-monorepo/libs/ali-green/src/index.ts b/project/aitoearn-monorepo/libs/ali-green/src/index.ts new file mode 100644 index 000000000..3da975a4f --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/src/index.ts @@ -0,0 +1,2 @@ +export * from './ali-green-api.module' +export * from './ali-green-api.service' diff --git a/project/aitoearn-monorepo/libs/ali-green/tsconfig.json b/project/aitoearn-monorepo/libs/ali-green/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/ali-green/tsconfig.lib.json b/project/aitoearn-monorepo/libs/ali-green/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ali-green/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/ansible/README.md b/project/aitoearn-monorepo/libs/ansible/README.md new file mode 100644 index 000000000..114000ea2 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/README.md @@ -0,0 +1,45 @@ +# @yikart/ansible + +A TypeScript library for interacting with Ansible automation platform. + +## Features + +- Execute Ansible playbooks +- Manage inventory +- Run ad-hoc commands +- Type-safe interfaces +- NestJS integration + +## Installation + +```bash +npm install @yikart/ansible +``` + +## Usage + +```typescript +import { AnsibleService } from './src' + +const ansibleService = new AnsibleService({ + inventoryPath: '/path/to/inventory', + playbookPath: '/path/to/playbooks' +}) + +// Execute a playbook +await ansibleService.runPlaybook('site.yml', { + limit: 'webservers', + extraVars: { version: '1.0.0' } +}) + +// Run ad-hoc command +await ansibleService.runCommand('all', 'uptime') +``` + +## Configuration + +The library supports various configuration options for customizing Ansible execution. + +## License + +MIT diff --git a/project/aitoearn-monorepo/libs/ansible/eslint.config.mjs b/project/aitoearn-monorepo/libs/ansible/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/ansible/package.json b/project/aitoearn-monorepo/libs/ansible/package.json new file mode 100644 index 000000000..79ee8bc58 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/package.json @@ -0,0 +1,23 @@ +{ + "name": "@yikart/ansible", + "type": "commonjs", + "version": "0.0.2", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@yikart/common": "*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "execa": "^9.6.0" + } +} diff --git a/project/aitoearn-monorepo/libs/ansible/project.json b/project/aitoearn-monorepo/libs/ansible/project.json new file mode 100644 index 000000000..cb2470314 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/project.json @@ -0,0 +1,42 @@ +{ + "name": "ansible", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/ansible/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/ansible", + "tsConfig": "libs/ansible/tsconfig.lib.json", + "packageJson": "libs/ansible/package.json", + "main": "libs/ansible/src/index.ts", + "assets": ["libs/ansible/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/ansible/src/ansible.config.ts b/project/aitoearn-monorepo/libs/ansible/src/ansible.config.ts new file mode 100644 index 000000000..c49a6a9f3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/ansible.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const ansibleConfigSchema = z.object({ + timeout: z.int().positive().optional().default(300), + forks: z.int().positive().optional().default(5), + verbosity: z.int().min(0).max(4).optional().default(0), +}) + +export class AnsibleConfig extends createZodDto(ansibleConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/ansible/src/ansible.exception.ts b/project/aitoearn-monorepo/libs/ansible/src/ansible.exception.ts new file mode 100644 index 000000000..1fb42b72d --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/ansible.exception.ts @@ -0,0 +1,50 @@ +export class AnsibleException extends Error { + constructor( + message: string, + public readonly command?: string, + public readonly exitCode?: number, + public readonly stderr?: string, + ) { + super(message) + this.name = 'AnsibleException' + } +} + +export class AnsiblePlaybookException extends AnsibleException { + constructor( + message: string, + public readonly playbook: string, + command?: string, + exitCode?: number, + stderr?: string, + ) { + super(message, command, exitCode, stderr) + this.name = 'AnsiblePlaybookException' + } +} + +export class AnsibleInventoryException extends AnsibleException { + constructor( + message: string, + public readonly inventory: string, + command?: string, + exitCode?: number, + stderr?: string, + ) { + super(message, command, exitCode, stderr) + this.name = 'AnsibleInventoryException' + } +} + +export class AnsibleCommandException extends AnsibleException { + constructor( + message: string, + public readonly module: string, + command?: string, + exitCode?: number, + stderr?: string, + ) { + super(message, command, exitCode, stderr) + this.name = 'AnsibleCommandException' + } +} diff --git a/project/aitoearn-monorepo/libs/ansible/src/ansible.interface.ts b/project/aitoearn-monorepo/libs/ansible/src/ansible.interface.ts new file mode 100644 index 000000000..bfa9a570f --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/ansible.interface.ts @@ -0,0 +1,86 @@ +export interface PlaybookOptions { + inventory?: string + limit?: string + tags?: string[] + skipTags?: string[] + extraVars?: Record + check?: boolean + diff?: boolean + verbose?: boolean + become?: boolean + becomeUser?: string + becomeMethod?: string + askBecomePass?: boolean + // SSH connection options + connection?: string + user?: string + askPass?: boolean + privateKeyFile?: string + sshCommonArgs?: string + sshExtraArgs?: string +} + +export interface AdHocOptions { + module?: string + args?: string + inventory?: string + limit?: string + extraVars?: Record + become?: boolean + becomeUser?: string + forks?: number + timeout?: number + // SSH connection options + connection?: string + user?: string + askPass?: boolean + privateKeyFile?: string + sshCommonArgs?: string + sshExtraArgs?: string +} + +export interface AnsibleResult { + success: boolean + stdout: string + stderr: string + exitCode?: number +} + +export interface InventoryHost { + name: string + groups: string[] + variables: Record +} + +export interface InventoryGroup { + name: string + hosts: string[] + children: string[] + variables: Record +} + +export interface Inventory { + hosts: InventoryHost[] + groups: InventoryGroup[] +} + +export interface PlaybookTask { + name: string + module: string + args: Record + when?: string + tags?: string[] + become?: boolean +} + +export interface PlaybookPlay { + name: string + hosts: string + vars?: Record + tasks: PlaybookTask[] + handlers?: PlaybookTask[] +} + +export interface Playbook { + plays: PlaybookPlay[] +} diff --git a/project/aitoearn-monorepo/libs/ansible/src/ansible.module.ts b/project/aitoearn-monorepo/libs/ansible/src/ansible.module.ts new file mode 100644 index 000000000..19c5bbf0f --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/ansible.module.ts @@ -0,0 +1,21 @@ +import { DynamicModule, Module } from '@nestjs/common' +import { AnsibleConfig } from './ansible.config' +import { AnsibleService } from './ansible.service' + +@Module({}) +export class AnsibleModule { + static forRoot(config: AnsibleConfig): DynamicModule { + return { + module: AnsibleModule, + providers: [ + { + provide: AnsibleConfig, + useValue: config, + }, + AnsibleService, + ], + exports: [AnsibleService], + global: true, + } + } +} diff --git a/project/aitoearn-monorepo/libs/ansible/src/ansible.service.ts b/project/aitoearn-monorepo/libs/ansible/src/ansible.service.ts new file mode 100644 index 000000000..4b2ba8b05 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/ansible.service.ts @@ -0,0 +1,424 @@ +import { execSync } from 'node:child_process' +import { Injectable, Logger } from '@nestjs/common' +import { execa } from 'execa' +import { AnsibleConfig } from './ansible.config' +import { + AnsibleCommandException, + AnsibleException, + AnsibleInventoryException, + AnsiblePlaybookException, +} from './ansible.exception' +import { + AdHocOptions, + AnsibleResult, + Inventory, + InventoryGroup, + InventoryHost, + PlaybookOptions, +} from './ansible.interface' + +@Injectable() +export class AnsibleService { + private readonly logger = new Logger(AnsibleService.name) + + constructor(private readonly config: AnsibleConfig) { + this.checkAnsibleInstallation() + } + + /** + * Check if Ansible CLI is installed on the system + */ + private checkAnsibleInstallation(): void { + if (this.isAnsibleInstalled()) { + this.logger.debug('Ansible CLI is available') + } + else { + const errorMessage = 'Ansible CLI is not installed. Please run: pnpm install to automatically install it, or install manually with: pip3 install ansible' + this.logger.error(errorMessage) + throw new AnsibleException( + errorMessage, + 'ansible-playbook --version', + ) + } + } + + /** + * Check if Ansible CLI is available without throwing an error + */ + private isAnsibleInstalled(): boolean { + try { + execSync('ansible-playbook --version', { stdio: 'ignore' }) + return true + } + catch { + return false + } + } + + /** + * Execute an Ansible playbook + */ + async runPlaybook( + playbookName: string, + options: PlaybookOptions = {}, + ): Promise { + const playbookPath = this.resolvePlaybookPath(playbookName) + const command = this.buildPlaybookCommand(playbookPath, options) + + try { + const result = await this.executeCommand(command) + + if (!result.success) { + throw new AnsiblePlaybookException( + `Playbook execution failed: ${playbookName}`, + playbookName, + command.join(' '), + result.exitCode, + result.stderr, + ) + } + + return result + } + catch (error) { + this.logger.error(`Failed to execute playbook ${playbookName}:`, error) + throw error + } + } + + /** + * Run an ad-hoc Ansible command + */ + async runCommand( + hosts: string, + module: string, + args?: string, + options: AdHocOptions = {}, + ): Promise { + const command = this.buildAdHocCommand(hosts, module, args, options) + + try { + const result = await this.executeCommand(command) + + if (!result.success) { + throw new AnsibleCommandException( + `Ad-hoc command failed: ${module}`, + module, + command.join(' '), + result.exitCode, + result.stderr, + ) + } + + return result + } + catch (error) { + this.logger.error(`Failed to execute ad-hoc command ${module}:`, error) + throw error + } + } + + /** + * Get inventory information + */ + async getInventory(inventoryPath: string): Promise { + const invPath = inventoryPath + + const command = ['ansible-inventory', '-i', invPath, '--list'] + + try { + const result = await this.executeCommand(command) + + if (!result.success) { + throw new AnsibleInventoryException( + 'Failed to get inventory', + invPath, + command.join(' '), + result.exitCode, + result.stderr, + ) + } + + return this.parseInventoryOutput(result.stdout) + } + catch (error) { + this.logger.error('Failed to get inventory:', error) + throw error + } + } + + /** + * Check if a host is reachable + */ + async ping(hosts: string, options: AdHocOptions = {}): Promise { + return this.runCommand(hosts, 'ping', undefined, options) + } + + /** + * Gather facts from hosts + */ + async gatherFacts(hosts: string, options: AdHocOptions = {}): Promise { + return this.runCommand(hosts, 'setup', undefined, options) + } + + private resolvePlaybookPath(playbookName: string): string { + return playbookName + } + + private buildPlaybookCommand(playbookPath: string, options: PlaybookOptions): string[] { + const command = ['ansible-playbook'] + + // Add inventory + if (options.inventory) { + command.push('-i', options.inventory) + } + + // Add limit + if (options.limit) { + command.push('--limit', options.limit) + } + + // Add tags + if (options.tags && options.tags.length > 0) { + command.push('--tags', options.tags.join(',')) + } + + // Add skip tags + if (options.skipTags && options.skipTags.length > 0) { + command.push('--skip-tags', options.skipTags.join(',')) + } + + // Add extra vars + if (options.extraVars) { + command.push('--extra-vars', `'${JSON.stringify(options.extraVars)}'`) + } + + // Add check mode + if (options.check) { + command.push('--check') + } + + // Add diff + if (options.diff) { + command.push('--diff') + } + + // Add become options + if (options.become) { + command.push('--become') + if (options.becomeUser) { + command.push('--become-user', options.becomeUser) + } + if (options.becomeMethod) { + command.push('--become-method', options.becomeMethod) + } + if (options.askBecomePass) { + command.push('--ask-become-pass') + } + } + + // Add verbosity + if (options.verbose || this.config.verbosity > 0) { + const verbosity = Math.max(options.verbose ? 1 : 0, this.config.verbosity) + if (verbosity > 0) { + command.push(`-${'v'.repeat(verbosity)}`) + } + } + + if (options.connection) { + command.push('--connection', options.connection) + } + + if (options.user) { + command.push('--user', options.user) + } + + if (options.askPass) { + command.push('--ask-pass') + } + + if (options.privateKeyFile) { + command.push('--private-key', options.privateKeyFile) + } + + if (options.sshCommonArgs) { + command.push('--ssh-common-args', options.sshCommonArgs) + } + + if (options.sshExtraArgs) { + command.push('--ssh-extra-args', options.sshExtraArgs) + } + + // Add forks + if (this.config.forks !== 5) { + command.push('--forks', this.config.forks.toString()) + } + + // Add playbook path + command.push(playbookPath) + + return command + } + + private buildAdHocCommand( + hosts: string, + module: string, + args?: string, + options: AdHocOptions = {}, + ): string[] { + const command = ['ansible'] + + // Add hosts pattern + command.push(hosts) + + // Add inventory + if (options.inventory) { + command.push('-i', options.inventory) + } + + // Add module + command.push('-m', options.module || module) + + // Add module arguments + if (args || options.args) { + command.push('-a', args || options.args!) + } + + // Add limit + if (options.limit) { + command.push('--limit', options.limit) + } + + // Add extra vars + if (options.extraVars) { + command.push('--extra-vars', `'${JSON.stringify(options.extraVars)}'`) + } + + // Add become options + if (options.become) { + command.push('--become') + if (options.becomeUser) { + command.push('--become-user', options.becomeUser) + } + } + + // Add forks + if (options.forks) { + command.push('--forks', options.forks.toString()) + } + + // Add timeout + if (options.timeout) { + command.push('--timeout', options.timeout.toString()) + } + + if (options.connection) { + command.push('--connection', options.connection) + } + + if (options.user) { + command.push('--user', options.user) + } + + if (options.askPass) { + command.push('--ask-pass') + } + + if (options.privateKeyFile) { + command.push('--private-key', options.privateKeyFile) + } + + if (options.sshCommonArgs) { + command.push('--ssh-common-args', options.sshCommonArgs) + } + + if (options.sshExtraArgs) { + command.push('--ssh-extra-args', options.sshExtraArgs) + } + + return command + } + + private async executeCommand(command: string[]): Promise { + const commandStr = command.join(' ') + + this.logger.debug(`Executing command: ${commandStr}`) + + const result = await execa(command[0], command.slice(1), { + timeout: this.config.timeout * 60 * 1000, + reject: false, + }) + + this.logger.debug({ + result, + }) + + if (result.timedOut) { + throw new AnsibleException( + `Command timed out after ${this.config.timeout}ms`, + commandStr, + ) + } + + const ansibleResult: AnsibleResult = { + success: result.exitCode === 0, + ...result, + } + + this.logger.debug({ + message: `Command completed in ${result.durationMs}ms with exit code ${result.exitCode}`, + ...ansibleResult, + }) + + return ansibleResult + } + + private parseInventoryOutput(output: string): Inventory { + try { + const data = JSON.parse(output) as Record + const hosts: InventoryHost[] = [] + const groups: InventoryGroup[] = [] + + // Parse groups + for (const [groupName, groupData] of Object.entries(data)) { + if (groupName === '_meta') + continue + + const groupDataObj = groupData as Record + const group: InventoryGroup = { + name: groupName, + hosts: (groupDataObj['hosts'] as string[]) || [], + children: (groupDataObj['children'] as string[]) || [], + variables: (groupDataObj['vars'] as Record) || {}, + } + groups.push(group) + } + + // Parse hosts from _meta.hostvars + const meta = data['_meta'] as Record + if (meta && meta['hostvars']) { + const hostvars = meta['hostvars'] as Record + for (const [hostName, hostVars] of Object.entries(hostvars)) { + const hostGroups = groups + .filter(group => group.hosts.includes(hostName)) + .map(group => group.name) + + const host: InventoryHost = { + name: hostName, + groups: hostGroups, + variables: hostVars as Record, + } + hosts.push(host) + } + } + + return { hosts, groups } + } + catch (error) { + throw new AnsibleInventoryException( + `Failed to parse inventory output: ${error instanceof Error ? error.message : 'Unknown error'}`, + '', + ) + } + } +} diff --git a/project/aitoearn-monorepo/libs/ansible/src/index.ts b/project/aitoearn-monorepo/libs/ansible/src/index.ts new file mode 100644 index 000000000..5e7f8c36b --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/src/index.ts @@ -0,0 +1,7 @@ +export * from './ansible.config' + +export * from './ansible.exception' +export * from './ansible.interface' + +export * from './ansible.module' +export * from './ansible.service' diff --git a/project/aitoearn-monorepo/libs/ansible/tsconfig.json b/project/aitoearn-monorepo/libs/ansible/tsconfig.json new file mode 100644 index 000000000..3c7ea7437 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "forceConsistentCasingInFileNames": true + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/ansible/tsconfig.lib.json b/project/aitoearn-monorepo/libs/ansible/tsconfig.lib.json new file mode 100644 index 000000000..b1ae6db58 --- /dev/null +++ b/project/aitoearn-monorepo/libs/ansible/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "strictBindCallApply": true, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc", + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/README.md b/project/aitoearn-monorepo/libs/aws-s3/README.md new file mode 100644 index 000000000..b86123b2b --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/README.md @@ -0,0 +1,7 @@ +# aws-s3 + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build aws-s3` to build the library. diff --git a/project/aitoearn-monorepo/libs/aws-s3/eslint.config.mjs b/project/aitoearn-monorepo/libs/aws-s3/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/aws-s3/package.json b/project/aitoearn-monorepo/libs/aws-s3/package.json new file mode 100644 index 000000000..415ea67f2 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/package.json @@ -0,0 +1,26 @@ +{ + "name": "@yikart/aws-s3", + "type": "commonjs", + "version": "0.0.3", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@yikart/common": "*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.886.0", + "@aws-sdk/lib-storage": "^3.886.0", + "@aws-sdk/s3-presigned-post": "^3.886.0", + "@aws-sdk/s3-request-presigner": "^3.886.0" + } +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/project.json b/project/aitoearn-monorepo/libs/aws-s3/project.json new file mode 100644 index 000000000..3f066661e --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/project.json @@ -0,0 +1,48 @@ +{ + "name": "aws-s3", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/aws-s3/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": [ + "dist/{projectRoot}" + ], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": [ + "{options.outputPath}" + ], + "options": { + "outputPath": "dist/libs/aws-s3", + "tsConfig": "libs/aws-s3/tsconfig.lib.json", + "packageJson": "libs/aws-s3/package.json", + "main": "libs/aws-s3/src/index.ts", + "assets": [ + "libs/aws-s3/*.md" + ] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/index.ts b/project/aitoearn-monorepo/libs/aws-s3/src/index.ts new file mode 100644 index 000000000..1b390ff96 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/index.ts @@ -0,0 +1,4 @@ +export * from './s3.config' +export * from './s3.module' +export * from './s3.service' +export * from './s3.util' diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.config.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.config.ts new file mode 100644 index 000000000..5d5c9e7cc --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/s3.config.ts @@ -0,0 +1,13 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const s3ConfigSchema = z.object({ + region: z.string(), + accessKeyId: z.string().optional(), + secretAccessKey: z.string().optional(), + bucketName: z.string(), + endpoint: z.httpUrl(), + signExpires: z.number().default(5 * 60).describe('sign expires in seconds'), +}) + +export class S3Config extends createZodDto(s3ConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.factory.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.factory.ts new file mode 100644 index 000000000..17a9bd5b4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/s3.factory.ts @@ -0,0 +1,16 @@ +import { S3Client } from '@aws-sdk/client-s3' +import { S3Config } from './s3.config' + +export class S3Factory { + static createS3Client(options: S3Config): S3Client { + return new S3Client({ + region: options.region, + credentials: options.accessKeyId && options.secretAccessKey + ? { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + } + : undefined, + }) + } +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.interface.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.interface.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.module.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.module.ts new file mode 100644 index 000000000..d496a1c58 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/s3.module.ts @@ -0,0 +1,34 @@ +import type { DynamicModule, Provider } from '@nestjs/common' +import { S3Client } from '@aws-sdk/client-s3' +import { Global, Module } from '@nestjs/common' +import { S3Config } from './s3.config' +import { S3Factory } from './s3.factory' +import { S3Service } from './s3.service' + +@Global() +@Module({}) +export class S3Module { + static forRoot(config: S3Config): DynamicModule { + const providers: Provider[] = [ + { + provide: S3Config, + useValue: config, + }, + { + provide: S3Client, + useFactory: (s3Config: S3Config) => { + return S3Factory.createS3Client(s3Config) + }, + inject: [S3Config], + }, + S3Service, + ] + + return { + global: true, + module: S3Module, + providers, + exports: [S3Service], + } + } +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.service.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.service.ts new file mode 100644 index 000000000..8f83417f3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/s3.service.ts @@ -0,0 +1,153 @@ +import { + CompleteMultipartUploadCommand, + CreateMultipartUploadCommand, + DeleteObjectCommand, + HeadObjectCommand, + PutObjectCommandInput, + S3Client, + UploadPartCommand, + UploadPartCommandInput, + UploadPartCommandOutput, +} from '@aws-sdk/client-s3' +import { Upload } from '@aws-sdk/lib-storage' +import { createPresignedPost } from '@aws-sdk/s3-presigned-post' +import { Injectable, Logger } from '@nestjs/common' +import { AppException, ResponseCode } from '@yikart/common' +import { S3Config } from './s3.config' +import { buildUrl } from './s3.util' + +@Injectable() +export class S3Service { + private readonly logger = new Logger(S3Service.name) + constructor( + private readonly config: S3Config, + private readonly client: S3Client, + ) { + } + + async putObject( + objectPath: string, + file: PutObjectCommandInput['Body'], + ) { + const upload = new Upload({ + client: this.client, + params: { + Bucket: this.config.bucketName, + Key: objectPath, + Body: file, + }, + }) + await upload.done() + return { path: objectPath } + } + + async headObject(objectPath: string) { + const command = new HeadObjectCommand({ + Bucket: this.config.bucketName, + Key: objectPath, + }) + return await this.client.send(command) + } + + async putObjectFromUrl( + url: string, + objectPath: string, + ) { + try { + await this.headObject(objectPath) + return { path: objectPath, exists: true } + } + catch { + const response = await fetch(url) + if (response.body === null) { + throw new AppException(ResponseCode.S3DownloadFileFailed) + } + return this.putObject(objectPath, response.body) + } + } + + // 生成预签名上传 URL + getUploadSign(objectPath: string) { + return createPresignedPost(this.client, { + Bucket: this.config.bucketName, + Key: objectPath, + Expires: this.config.signExpires, + }) + } + + /** + * 开始分片上传 + * @param {string} objectPath - 文件键 + * @returns {Promise} - 返回上传ID + */ + async initiateMultipartUpload( + objectPath: string, + ): Promise { + const command = new CreateMultipartUploadCommand({ + Bucket: this.config.bucketName, + Key: objectPath, + }) + + const response = await this.client.send(command) + return response.UploadId! + } + + /** + * 上传单个分片 + * @param {string} objectPath - 文件键 + * @param {string} uploadId - 上传ID + * @param {number} partNumber - 分片编号 + * @param {Buffer} partData - 分片数据 + * @returns {Promise} - 返回ETag + */ + async uploadPart( + objectPath: string, + uploadId: string, + partNumber: number, + partData: UploadPartCommandInput['Body'], + ): Promise { + const command = new UploadPartCommand({ + Bucket: this.config.bucketName, + Key: objectPath, + UploadId: uploadId, + PartNumber: partNumber, + Body: partData, + }) + + return await this.client.send(command) + } + + /** + * 完成分片上传 + * @param {string} objectPath - 文件键 + * @param {string} uploadId - 上传ID + * @param {Array<{ PartNumber: number; ETag: string }>} parts - 分片列表 + */ + async completeMultipartUpload( + objectPath: string, + uploadId: string, + parts: { PartNumber: number, ETag: string }[], + ): Promise { + const command = new CompleteMultipartUploadCommand({ + Bucket: this.config.bucketName, + Key: objectPath, + UploadId: uploadId, + MultipartUpload: { Parts: parts }, + }) + + await this.client.send(command) + } + + // 删除文件 + async deleteObject(objectPath: string) { + const command = new DeleteObjectCommand({ + Bucket: this.config.bucketName, + Key: objectPath, + }) + await this.client.send(command) + } + + buildUrl(objectPath: string) { + return buildUrl(this.config.endpoint, objectPath) + } +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/src/s3.util.ts b/project/aitoearn-monorepo/libs/aws-s3/src/s3.util.ts new file mode 100644 index 000000000..515fa7099 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/src/s3.util.ts @@ -0,0 +1,48 @@ +import { z } from 'zod' + +export function buildUrl(endpoint: string, objectPath: string) { + const normalizedPath = String(objectPath ?? '').trim() + + if (normalizedPath && (normalizedPath.startsWith('http://') || normalizedPath.startsWith('https://'))) { + return normalizedPath + } + + const trimmedEndpoint = endpoint.trim().replace(/\/+$/g, '') + + const pathWithoutLeadingSlash = normalizedPath.replace(/^\/+/, '') + + const encodedPath = pathWithoutLeadingSlash + ? pathWithoutLeadingSlash + .split('/') + .map(segment => encodeURIComponent(segment)) + .join('/') + : '' + + return encodedPath ? `${trimmedEndpoint}/${encodedPath}` : trimmedEndpoint +} + +export function zodBuildUrl(endpoint: string) { + const validatedEndpoint = z.httpUrl().parse(endpoint) + return z + .string() + .optional() + .transform((objectPath) => { + if (!objectPath) { + return objectPath + } + return buildUrl(validatedEndpoint, objectPath) + }) +} + +// 去除前置host地址的zod +export function zodTrimHost(endpoint: string) { + const validatedEndpoint = z.httpUrl().parse(endpoint) + return z + .string() + .transform((url) => { + if (!url) { + return url + } + return url.replace(validatedEndpoint, '') + }) +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/tsconfig.json b/project/aitoearn-monorepo/libs/aws-s3/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/aws-s3/tsconfig.lib.json b/project/aitoearn-monorepo/libs/aws-s3/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/aws-s3/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/common/README.md b/project/aitoearn-monorepo/libs/common/README.md new file mode 100644 index 000000000..f8201cfd0 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/README.md @@ -0,0 +1,7 @@ +# common + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build common` to build the library. diff --git a/project/aitoearn-monorepo/libs/common/eslint.config.mjs b/project/aitoearn-monorepo/libs/common/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/common/package.json b/project/aitoearn-monorepo/libs/common/package.json new file mode 100644 index 000000000..7bfc7af6f --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/package.json @@ -0,0 +1,41 @@ +{ + "name": "@yikart/common", + "type": "commonjs", + "version": "0.0.10", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.1.6", + "express": "5.1.0", + "rxjs": "^7.8.2", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.864.0", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.1.6", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/swagger": "^11.2.0", + "@scalar/nestjs-api-reference": "^0.5.12", + "commander": "^14.0.0", + "nanoid": "^5.1.5", + "nest-typed-config": "^2.10.1", + "nestjs-pino": "^4.4.0", + "pino": "^9.7.0", + "pino-pretty": "^13.1.1" + }, + "devDependencies": { + "@types/express": "^5.0.3" + } +} diff --git a/project/aitoearn-monorepo/libs/common/project.json b/project/aitoearn-monorepo/libs/common/project.json new file mode 100644 index 000000000..5a9a9e8e8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/project.json @@ -0,0 +1,42 @@ +{ + "name": "common", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/common/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/common", + "tsConfig": "libs/common/tsconfig.lib.json", + "packageJson": "libs/common/package.json", + "main": "libs/common/src/index.ts", + "assets": ["libs/common/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/config.ts b/project/aitoearn-monorepo/libs/common/src/config.ts new file mode 100644 index 000000000..131480ad2 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/config.ts @@ -0,0 +1,64 @@ +import { z } from 'zod' + +export const natsConfig = z.object({ + name: z.string().optional(), + servers: z.array(z.string()).optional(), + user: z.string().optional(), + pass: z.string().optional(), + prefix: z.string().optional(), +}) + +export const openapiConfig = z.object({ + enable: z.boolean().default(false), + title: z.string().default('API Reference'), + description: z.string().default('API Reference'), + path: z.string().default('/docs'), +}) + +const logLevel = z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']) + +export const cloudWatchLoggerConfig = z.object({ + enable: z.boolean().default(false), + level: logLevel.default('debug'), + region: z.string(), + group: z.string(), + stream: z.string().optional(), + entity: z.object({ + keyAttributes: z.record(z.string(), z.string()).optional(), + attributes: z.record(z.string(), z.string()).optional(), + }).optional(), + accessKeyId: z.string().optional(), + secretAccessKey: z.string().optional(), +}) + +export const consoleLoggerConfig = z.object({ + enable: z.boolean().default(true), + level: logLevel.default('info'), + singleLine: z.boolean().default(false), + translateTime: z.boolean().default(true), +}) + +export const feishuLoggerConfig = z.object({ + enable: z.boolean().default(false), + level: logLevel.default('fatal'), + url: z.url(), + secret: z.string(), +}) + +export const loggerConfig = z.object({ + cloudWatch: cloudWatchLoggerConfig.optional(), + console: consoleLoggerConfig.optional(), + feishu: feishuLoggerConfig.optional(), +}) + +export const baseConfig = z.object({ + globalPrefix: z.string().optional(), + port: z.number().int().default(3000), + enableConfigLogging: z.boolean().default(false), + enableBadRequestDetails: z.boolean().default(false), + openapi: openapiConfig.optional(), + logger: loggerConfig.optional(), + nats: natsConfig.optional(), +}) + +export type BaseConfig = z.infer diff --git a/project/aitoearn-monorepo/libs/common/src/decorators/api-doc.decorator.ts b/project/aitoearn-monorepo/libs/common/src/decorators/api-doc.decorator.ts new file mode 100644 index 000000000..75d0719b4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/decorators/api-doc.decorator.ts @@ -0,0 +1,161 @@ +import type { Type } from '@nestjs/common' +import type { ReferenceObject, SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' +import type { ZodType } from 'zod' +import { applyDecorators } from '@nestjs/common' +import { ApiBody, ApiExtraModels, ApiOperation, ApiQuery, ApiResponse } from '@nestjs/swagger' +import { z } from 'zod' +import { zodToJsonSchemaOptions } from '../utils' + +export interface ApiDocOptions { + /** + * 接口摘要 + */ + summary: string + + /** + * 接口详细描述 + */ + description: string + + /** + * 请求体 DTO Schema(可选) + */ + body?: ZodType + + /** + * 请求参数 DTO Schema(可选) + */ + query?: ZodType + + /** + * 响应 VO 类型 + */ + response?: Type | [Type] | ZodType +} + +/** + * Swagger 文档装饰器 + * 用于统一生成接口文档,支持分页响应和请求体验证 + * + * @param options 装饰器选项 + */ +export function ApiDoc(options: ApiDocOptions) { + const { + summary, + description, + body, + query, + response, + } = options + + const responseType = Array.isArray(response) ? response[0] : response + + const decorators: MethodDecorator[] = [ + ApiOperation({ + summary, + description, + }), + ] + + if (responseType && typeof responseType === 'function') { + decorators.push(ApiExtraModels(responseType)) + } + + if (body) { + const meta = z.globalRegistry.get(body) + let schemaObject: SchemaObject | ReferenceObject + if (meta && meta.id) { + schemaObject = { + $ref: `#/components/schemas/${meta.id}`, + } + } + else { + schemaObject = z.toJSONSchema(body, { ...zodToJsonSchemaOptions, io: 'input' }) as SchemaObject + } + decorators.push( + ApiBody({ + schema: schemaObject, + }), + ) + } + if (query) { + const meta = z.globalRegistry.get(query) + let schemaObject: SchemaObject | ReferenceObject + if (meta && meta.id) { + schemaObject = { + $ref: `#/components/schemas/${meta.id}`, + } + } + else { + schemaObject = z.toJSONSchema(query, { ...zodToJsonSchemaOptions, io: 'input' }) as SchemaObject + } + decorators.push( + ApiQuery({ + schema: schemaObject, + }), + ) + } + + let dataSchema: SchemaObject | ReferenceObject | undefined + if (responseType) { + if (typeof responseType === 'function') { + dataSchema = Array.isArray(response) + ? { + type: 'array', + items: { + $ref: `#/components/schemas/${responseType.name}`, + }, + } + : { + $ref: `#/components/schemas/${responseType.name}`, + } + } + else { + const meta = z.globalRegistry.get(responseType) + let schemaObject: SchemaObject | ReferenceObject + if (meta && meta.id) { + schemaObject = { + $ref: `#/components/schemas/${meta.id}`, + } + } + else { + schemaObject = z.toJSONSchema(responseType, { ...zodToJsonSchemaOptions, io: 'output' }) as SchemaObject + } + dataSchema = schemaObject + } + } + + decorators.push( + ApiResponse({ + status: 'default', + schema: { + type: 'object', + properties: { + ...(dataSchema ? { data: dataSchema } : {}), + }, + required: ['data'], + }, + }), + ApiResponse({ + + status: 'error' as unknown as '5XX', + schema: { + type: 'object', + properties: { + ...(dataSchema ? { data: dataSchema } : {}), + code: { + type: 'number', + description: '错误码', + }, + message: { + type: 'string', + description: '错误消息', + }, + }, + required: ['code', 'message'], + }, + }), + ) + + return applyDecorators(...decorators) +} diff --git a/project/aitoearn-monorepo/libs/common/src/decorators/index.ts b/project/aitoearn-monorepo/libs/common/src/decorators/index.ts new file mode 100755 index 000000000..1cf52084e --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/decorators/index.ts @@ -0,0 +1,2 @@ +export * from './api-doc.decorator' +export * from './nats-pattern.decorator' diff --git a/project/aitoearn-monorepo/libs/common/src/decorators/nats-pattern.decorator.ts b/project/aitoearn-monorepo/libs/common/src/decorators/nats-pattern.decorator.ts new file mode 100644 index 000000000..f5736d700 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/decorators/nats-pattern.decorator.ts @@ -0,0 +1,9 @@ +import { EventPattern, MessagePattern, Transport } from '@nestjs/microservices' + +export function NatsEventPattern(pattern: string): MethodDecorator { + return EventPattern(pattern, Transport.NATS) +} + +export function NatsMessagePattern(pattern: string): MethodDecorator { + return MessagePattern(pattern, Transport.NATS) +} diff --git a/project/aitoearn-monorepo/libs/common/src/dtos/index.ts b/project/aitoearn-monorepo/libs/common/src/dtos/index.ts new file mode 100644 index 000000000..43344785c --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/dtos/index.ts @@ -0,0 +1,3 @@ +export * from './pagination.dto' +export * from './range-filter.dto' +export * from './table.dto' diff --git a/project/aitoearn-monorepo/libs/common/src/dtos/pagination.dto.ts b/project/aitoearn-monorepo/libs/common/src/dtos/pagination.dto.ts new file mode 100644 index 000000000..b5cb9f461 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/dtos/pagination.dto.ts @@ -0,0 +1,8 @@ +import z from 'zod' + +export const PaginationDtoSchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(1000).default(10), +}) + +export type Pagination = z.infer diff --git a/project/aitoearn-monorepo/libs/common/src/dtos/range-filter.dto.ts b/project/aitoearn-monorepo/libs/common/src/dtos/range-filter.dto.ts new file mode 100644 index 000000000..d49481dbd --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/dtos/range-filter.dto.ts @@ -0,0 +1,11 @@ +import z, { ZodType } from 'zod' + +export type RangeFilter = [T, T] | [undefined, T] | [T, undefined] + +export function createRangeFilter(schema: ZodType): ZodType> { + return z.union([ + z.tuple([schema, schema]), + z.tuple([z.undefined(), schema]), + z.tuple([schema, z.undefined()]), + ]) +} diff --git a/project/aitoearn-monorepo/libs/common/src/dtos/table.dto.ts b/project/aitoearn-monorepo/libs/common/src/dtos/table.dto.ts new file mode 100644 index 000000000..52b43cbad --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/dtos/table.dto.ts @@ -0,0 +1,28 @@ +/* + * @Author: nevin + * @Date: 2022-03-17 18:14:52 + * @LastEditors: nevin + * @LastEditTime: 2024-10-10 15:45:59 + * @Description: 表单数据 + */ + +import { ApiProperty } from '@nestjs/swagger' +import z from 'zod' +import { createZodDto } from '../utils' + +export const TableDtoSchema = z.object({ + pageNo: z.union([z.string(), z.number()]).transform(Number).default(1), + pageSize: z.union([z.string(), z.number()]).transform(Number).default(10), +}) +export class TableDto extends createZodDto(TableDtoSchema) {} + +export class TableResDto { + @ApiProperty({ title: '页码', description: '页码' }) + readonly pageNo: number = 1 + + @ApiProperty({ title: '页数', description: '页数' }) + readonly pageSize: number = 10 + + @ApiProperty({ title: '总数', description: '总数' }) + readonly count: number = 0 +} diff --git a/project/aitoearn-monorepo/libs/common/src/enums/account-type.enum.ts b/project/aitoearn-monorepo/libs/common/src/enums/account-type.enum.ts new file mode 100644 index 000000000..1d68a7fea --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/account-type.enum.ts @@ -0,0 +1,16 @@ +export enum AccountType { + Douyin = 'douyin', // 抖音 + Xhs = 'xhs', // 小红书 + WxSph = 'wxSph', // 微信视频号 + KWAI = 'KWAI', // 快手 + YOUTUBE = 'youtube', // youtube + WxGzh = 'wxGzh', // 微信公众号 + BILIBILI = 'bilibili', // B站 + TWITTER = 'twitter', // twitter + TIKTOK = 'tiktok', // tiktok + FACEBOOK = 'facebook', // facebook + INSTAGRAM = 'instagram', // instagram + THREADS = 'threads', // threads + PINTEREST = 'pinterest', // pinterest + LINKEDIN = 'linkedin', // linkedin +} diff --git a/project/aitoearn-monorepo/libs/common/src/enums/filter-set.enum.ts b/project/aitoearn-monorepo/libs/common/src/enums/filter-set.enum.ts new file mode 100644 index 000000000..1933a85f1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/filter-set.enum.ts @@ -0,0 +1,26 @@ +export enum ConditionType { + Nested = 'nested', + Single = 'single', +} + +export enum Operator { + Equal = 'eq', + NotEqual = 'ne', + GreaterThan = 'gt', + LessThan = 'lt', + GreaterThanOrEqual = 'gte', + LessThanOrEqual = 'lte', + In = 'in', + NotIn = 'nin', + Like = 'like', + NotLike = 'nlike', + Between = 'btw', + NotBetween = 'nbtw', + IsNull = 'isn', + IsNotNull = 'isnn', +} + +export enum Conjunction { + And = 'and', + Or = 'or', +} diff --git a/project/aitoearn-monorepo/libs/common/src/enums/index.ts b/project/aitoearn-monorepo/libs/common/src/enums/index.ts new file mode 100755 index 000000000..a1b1565b1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/index.ts @@ -0,0 +1,5 @@ +export * from './account-type.enum' +export * from './filter-set.enum' +export * from './mime-type.enum' +export * from './response-code.enum' +export * from './user-type.enum' diff --git a/project/aitoearn-monorepo/libs/common/src/enums/mime-type.enum.ts b/project/aitoearn-monorepo/libs/common/src/enums/mime-type.enum.ts new file mode 100755 index 000000000..dea4739ab --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/mime-type.enum.ts @@ -0,0 +1,44 @@ +export enum ImageType { + JPEG = 'image/jpeg', + WEBP = 'image/webp', + PNG = 'image/png', + GIF = 'image/gif', + BMP = 'image/bmp', + SVG = 'image/svg+xml', + ICO = 'image/vnd.microsoft.icon', + TIFF = 'image/tiff', + AVIF = 'image/avif', + HEIC = 'image/heic', +} + +export enum DocumentType { + XLS = 'application/vnd.ms-excel', + XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + DOC = 'application/msword', + DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + PDF = 'application/pdf', + PPT = 'application/vnd.ms-powerpoint', + PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation', +} + +export enum VideoType { + MP4 = 'video/mp4', + WEBM = 'video/webm', + MOV = 'video/quicktime', + AVI = 'video/x-msvideo', + FLV = 'video/x-flv', + MKV = 'video/x-matroska', +} + +export enum AudioType { + MP3 = 'audio/mpeg', + OGG = 'audio/ogg', + WAV = 'audio/wav', + FLAC = 'audio/flac', + AAC = 'audio/aac', +} + +export enum TextType { + TEXT = 'text/plain', + CSV = 'text/csv', +} diff --git a/project/aitoearn-monorepo/libs/common/src/enums/response-code.enum.ts b/project/aitoearn-monorepo/libs/common/src/enums/response-code.enum.ts new file mode 100755 index 000000000..bf67d363e --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/response-code.enum.ts @@ -0,0 +1,75 @@ +export enum ResponseCode { + Success = 0, + + // 错误码域从 10000 开始,每个模块各自前缀 11000 12000 以此类推 + // 10 (libs) + // 10000 (common) + MailSendFail = 10001, + ValidationFailed = 10002, + + // 10100 (s3) + S3DownloadFileFailed = 10100, + + // 11000 (multilogin-account) + MultiloginAccountNotFound = 11000, + MultiloginAccountProfilesExceeded = 11001, + NoAvailableMultiloginAccount = 11002, + + // 11100 (cloud-space) + CloudSpaceNotFound = 11100, + CloudSpaceCreationFailed = 11101, + CloudSpaceNotInErrorStatus = 11102, + + // 11200 (cloud-instance) + UCloudInstanceCreationFailed = 11200, + UCloudInstanceNotFound = 11201, + UCloudInstanceDeletionFailed = 11202, + + // 11300 (browser-profile) + BrowserProfileNotFound = 11300, + + // 12000 (user) + UserNotFound = 12000, + UserPointsInsufficient = 12001, + UserStorageExceeded = 12002, + UserStatusError = 12003, + UserPasswordError = 12004, + UserLoginCodeError = 12005, + UserInviteCodeError = 12006, + + // 12100 (income) + IncomeRecordNotFound = 12100, + IncomeRecordNotWithdrawable = 12101, + UserInsufficientBalance = 12102, + + // 12200 (wallet-account) + UserWalletAccountAlreadyExists = 12200, + UserWalletAccountLimitExceeded = 12201, + + // 13000 (ai) + InvalidModel = 13000, + AiCallFailed = 13001, + InvalidAiTaskId = 13002, + AiLogNotFound = 13003, + + // 14000 (notification) + NotificationNotFound = 14000, + + // 15000 (payment) + WithdrawRecordExists = 15000, + + // 16000 (app-release) + AppReleaseNotFound = 16000, + AppReleaseAlreadyExists = 16001, + + // 17000 (accunt) + AccountNotFound = 17000, + + // 18000 (media) + MediaNotFound = 18000, + MediaGroupNotFound = 18001, + + // 19000 (material) + MaterialNotFound = 19000, + MaterialGroupNotFound = 19001, +} diff --git a/project/aitoearn-monorepo/libs/common/src/enums/user-type.enum.ts b/project/aitoearn-monorepo/libs/common/src/enums/user-type.enum.ts new file mode 100644 index 000000000..554371077 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/enums/user-type.enum.ts @@ -0,0 +1,4 @@ +export enum UserType { + User = 'user', + Admin = 'admin', +} diff --git a/project/aitoearn-monorepo/libs/common/src/exceptions/app.exception.ts b/project/aitoearn-monorepo/libs/common/src/exceptions/app.exception.ts new file mode 100755 index 000000000..0a13f2999 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/exceptions/app.exception.ts @@ -0,0 +1,34 @@ +import { HttpException, HttpStatus } from '@nestjs/common' +import { getCodeMessage } from '../utils' + +export class AppException extends HttpException { + constructor(code: number) + constructor(code: number, message: string) + constructor(code: number, data: unknown) + constructor(code: number, message: string, data: unknown) + constructor(code: number, message?: string | object, data?: unknown) { + if (typeof message === 'object') { + if (data) { + throw new Error('invalid AppException') + } + + data = message + message = undefined + } + + if (message === undefined) { + message = getCodeMessage(code) + } + + const payload = { + code, + message, + data, + } + + super( + HttpException.createBody(payload), + HttpStatus.BAD_REQUEST, + ) + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/exceptions/index.ts b/project/aitoearn-monorepo/libs/common/src/exceptions/index.ts new file mode 100755 index 000000000..ddfe0e811 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from './app.exception' +export * from './zod-validation.exception' diff --git a/project/aitoearn-monorepo/libs/common/src/exceptions/zod-validation.exception.ts b/project/aitoearn-monorepo/libs/common/src/exceptions/zod-validation.exception.ts new file mode 100644 index 000000000..0ff000af4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/exceptions/zod-validation.exception.ts @@ -0,0 +1,42 @@ +import type { ZodError } from 'zod' +import { + BadRequestException, + HttpStatus, + InternalServerErrorException, +} from '@nestjs/common' +import { z } from 'zod' + +export class ZodValidationException extends BadRequestException { + constructor(private error: ZodError) { + super({ + statusCode: HttpStatus.BAD_REQUEST, + message: 'Validation failed', + errors: z.treeifyError(error), + }) + } + + public getZodError() { + return this.error + } +} + +export class ZodSerializationException extends InternalServerErrorException { + // eslint-disable-next-line node/handle-callback-err + constructor(private error: ZodError) { + super() + } + + public getZodError() { + return this.error + } +} + +export type ZodExceptionCreator = (error: ZodError) => Error + +export const createZodValidationException: ZodExceptionCreator = (error) => { + return new ZodValidationException(error) +} + +export const createZodSerializationException: ZodExceptionCreator = (error) => { + return new ZodSerializationException(error) +} diff --git a/project/aitoearn-monorepo/libs/common/src/filters/global-exception.filter.ts b/project/aitoearn-monorepo/libs/common/src/filters/global-exception.filter.ts new file mode 100755 index 000000000..77537e277 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/filters/global-exception.filter.ts @@ -0,0 +1,71 @@ +import type { + ArgumentsHost, + ExceptionFilter, +} from '@nestjs/common' +import type { Response } from 'express' + +import type { Observable } from 'rxjs' +import type { CommonResponse } from '../interfaces' +import { + Catch, + HttpException, + InternalServerErrorException, + Logger, +} from '@nestjs/common' +import { of } from 'rxjs' +import { getExceptionPayload } from '../utils/exception.util' + +export interface GlobalExceptionFilterOptions { + returnBadRequestDetails?: boolean +} + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + protected readonly logger = new Logger(GlobalExceptionFilter.name) + constructor(private options: GlobalExceptionFilterOptions = {}) { } + + catch(exception: T, host: ArgumentsHost): void | Observable> { + if ( + !(exception instanceof HttpException) + || exception instanceof InternalServerErrorException + ) { + this.logger.fatal(exception) + } + else { + this.logger.error(exception) + } + + const payload = getExceptionPayload(exception, this.options.returnBadRequestDetails) + + return this.handleError(host, { + ...payload, + timestamp: Date.now(), + }) + } + + handleError(host: ArgumentsHost, payload: CommonResponse) { + const type = host.getType() + + if (type === 'rpc') { + return this.handleRpcError(host, payload) + } + return this.handleHttpError(host, payload) + } + + private handleRpcError( + host: ArgumentsHost, + payload: CommonResponse, + ) { + return of(payload) + } + + private handleHttpError( + host: ArgumentsHost, + payload: CommonResponse, + ) { + const ctx = host.switchToHttp() + const response = ctx.getResponse() + + response.status(200).json(payload) + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/filters/index.ts b/project/aitoearn-monorepo/libs/common/src/filters/index.ts new file mode 100644 index 000000000..554ea9d9c --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/filters/index.ts @@ -0,0 +1 @@ +export * from './global-exception.filter' diff --git a/project/aitoearn-monorepo/libs/common/src/index.ts b/project/aitoearn-monorepo/libs/common/src/index.ts new file mode 100644 index 000000000..d4e0ec516 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/index.ts @@ -0,0 +1,13 @@ +export * from './config' +export * from './decorators' +export * from './dtos' +export * from './enums' +export * from './exceptions' +export * from './filters' +export * from './interceptors' +export * from './interfaces' +export * from './loggers' +export * from './pipes' +export * from './starter' +export * from './utils' +export * from './vos' diff --git a/project/aitoearn-monorepo/libs/common/src/interceptors/index.ts b/project/aitoearn-monorepo/libs/common/src/interceptors/index.ts new file mode 100644 index 000000000..f3a69d270 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/interceptors/index.ts @@ -0,0 +1,2 @@ +export * from './propagation.interceptor' +export * from './response.interceptor' diff --git a/project/aitoearn-monorepo/libs/common/src/interceptors/propagation.interceptor.ts b/project/aitoearn-monorepo/libs/common/src/interceptors/propagation.interceptor.ts new file mode 100644 index 000000000..3bafa1da3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/interceptors/propagation.interceptor.ts @@ -0,0 +1,75 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common' +import { SSE_METADATA } from '@nestjs/common/constants' +import { Observable } from 'rxjs' + +interface Store { + headers: Record +} + +export const propagationContext = new AsyncLocalStorage() + +export const COMMON_PROPAGATION_HEADERS = [ + 'x-request-id', + 'x-b3-traceid', + 'x-b3-spanid', + 'x-b3-parentspanid', + 'x-b3-sampled', + 'x-b3-flags', + 'x-ot-span-context', + 'grpc-trace-bin', + 'traceparent', + 'x-cloud-trace-context', + 'x-amzn-trace-id', +] + +@Injectable() +export class PropagationInterceptor implements NestInterceptor { + public intercept(context: ExecutionContext, next: CallHandler): Observable { + return propagationContext.run({ headers: this.getHeaders(context) }, () => next.handle()) + } + + private getHttpHeaders(context: ExecutionContext) { + const request = context.switchToHttp().getRequest() + const response = context.switchToHttp().getResponse() + + if (!Reflect.getMetadata(SSE_METADATA, context.getHandler())) + response.header('x-request-id', request.headers['x-request-id']) + + return request.headers + } + + private getWsHeaders(context: ExecutionContext) { + const host = context.switchToWs() + const socket = host.getClient() + const data = host.getData() + const headers = socket.handshake.headers + if (data?.headers) { + return { + ...headers, + ...data.headers, + } + } + return headers + } + + private getRpcHeaders(context: ExecutionContext) { + const rpcContext = context.switchToRpc().getContext() + const metadata = rpcContext.getMap ? rpcContext.getMap() : {} + return metadata + } + + private getHeaders(context: ExecutionContext) { + const type = context.getType() + if (type === 'http') + return this.getHttpHeaders(context) + + if (type === 'ws') + return this.getWsHeaders(context) + + if (type === 'rpc') + return this.getRpcHeaders(context) + + return {} + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/interceptors/response.interceptor.ts b/project/aitoearn-monorepo/libs/common/src/interceptors/response.interceptor.ts new file mode 100755 index 000000000..f21f75787 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/interceptors/response.interceptor.ts @@ -0,0 +1,70 @@ +import type { + CallHandler, + ExecutionContext, + NestInterceptor, +} from '@nestjs/common' +import type { Response } from 'express' +import type { CommonResponse } from '../interfaces' +import { + Logger, + StreamableFile, +} from '@nestjs/common' +import { RENDER_METADATA } from '@nestjs/common/constants' +import { map } from 'rxjs' +import { ResponseCode } from '../enums' + +export class ResponseInterceptor implements NestInterceptor { + private readonly logger = new Logger(ResponseInterceptor.name) + intercept(context: ExecutionContext, next: CallHandler) { + const type = context.getType() + + const isRender = Reflect.hasMetadata(RENDER_METADATA, context.getHandler()) + + if (type === 'http') { + const res = context.switchToHttp().getResponse() + + res.status(200) + + return next.handle().pipe( + map((data) => { + if (data instanceof StreamableFile || isRender) { + return data + } + + return { + data, + code: ResponseCode.Success, + message: '请求成功', + } + }), + ) + } + else if (type === 'rpc') { + const startAt = Date.now() + + const ctx = context.switchToRpc() + const req = ctx.getContext<{ args: string[] }>() + const url = req.args[0] || '' + const rpcData = ctx.getData() + this.logger.debug(rpcData, `-- ${startAt}-- [${url}] rpcData ----: `) + + return next.handle().pipe( + map((data: unknown): CommonResponse => { + const reqTime = Date.now() - startAt + if (reqTime >= 50) { + this.logger.verbose(`${url}::${reqTime}ms`) + } + + return { + data, + code: ResponseCode.Success, + message: '请求成功', + } + }), + ) + } + else { + return next.handle() + } + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/interfaces/index.ts b/project/aitoearn-monorepo/libs/common/src/interfaces/index.ts new file mode 100644 index 000000000..b6cba9106 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/interfaces/index.ts @@ -0,0 +1 @@ +export * from './response.interface' diff --git a/project/aitoearn-monorepo/libs/common/src/interfaces/response.interface.ts b/project/aitoearn-monorepo/libs/common/src/interfaces/response.interface.ts new file mode 100644 index 000000000..9028b7593 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/interfaces/response.interface.ts @@ -0,0 +1,6 @@ +export interface CommonResponse { + data?: T + code: number + message: string + timestamp?: number +} diff --git a/project/aitoearn-monorepo/libs/common/src/loggers/cloud-watch.logger.ts b/project/aitoearn-monorepo/libs/common/src/loggers/cloud-watch.logger.ts new file mode 100644 index 000000000..f8bdce80b --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/loggers/cloud-watch.logger.ts @@ -0,0 +1,93 @@ +import type { CloudWatchLogsClientConfig, Entity } from '@aws-sdk/client-cloudwatch-logs' +import type { DestinationStream } from 'pino' +import * as os from 'node:os' +import { debuglog } from 'node:util' +import { CloudWatchLogsClient, CreateLogGroupCommand, CreateLogStreamCommand, PutLogEventsCommand } from '@aws-sdk/client-cloudwatch-logs' + +const log = debuglog('app:cloud-watch:logger') + +export interface CloudWatchLoggerOptions extends CloudWatchLogsClientConfig { + accessKeyId?: string + secretAccessKey?: string + group: string + stream?: string + entity?: Entity +} + +export class CloudWatchLogger implements DestinationStream { + private readonly client: CloudWatchLogsClient + private writeQueue: Promise = Promise.resolve() + private readonly ready: Promise + + constructor(private readonly options: CloudWatchLoggerOptions) { + const credentials = options.accessKeyId && options.secretAccessKey + ? { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + } + : options.credentials + this.client = new CloudWatchLogsClient({ + ...options, + credentials, + }) + + log('creating logger') + + this.options.stream = options.stream || `${os.hostname()}-${process.pid}-${Date.now()}` + + this.ready = this.createLogGroup().then(() => this.createLogStream()) + } + + async createLogGroup() { + log(`creating log group: ${this.options.group}`) + const command = new CreateLogGroupCommand({ + logGroupName: this.options.group, + }) + await this.client.send(command) + .catch((e) => { + log(`creating log group: ${this.options.group} error ${e}`) + if (e.name !== 'ResourceAlreadyExistsException') + throw e + }) + + log(`created log group: ${this.options.group}`) + } + + async createLogStream() { + log(`creating log stream: ${this.options.stream}`) + const command = new CreateLogStreamCommand({ + logGroupName: this.options.group, + logStreamName: this.options.stream, + }) + await this.client.send(command) + .catch((e) => { + log(`creating log stream: ${this.options.stream} error ${e}`) + if (e.name !== 'ResourceAlreadyExistsException') + throw e + }) + log(`created log stream: ${this.options.stream}`) + } + + async write(msg: string): Promise { + this.writeQueue = this.writeQueue.then(async () => { + await this.ready + + const command = new PutLogEventsCommand({ + logGroupName: this.options.group, + logStreamName: this.options.stream, + entity: this.options.entity, + logEvents: [ + { + timestamp: Date.now(), + message: msg, + }, + ], + }) + + await this.client.send(command) + log(`put logs`) + }) + + return this.writeQueue + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/loggers/console.logger.ts b/project/aitoearn-monorepo/libs/common/src/loggers/console.logger.ts new file mode 100644 index 000000000..f05d4f102 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/loggers/console.logger.ts @@ -0,0 +1,14 @@ +import type { DestinationStream } from 'pino' +import PinoPretty from 'pino-pretty' + +export class ConsoleLogger implements DestinationStream { + private readonly stream: PinoPretty.PrettyStream + + constructor(options: PinoPretty.PrettyOptions) { + this.stream = PinoPretty({ ...options, colorize: true, destination: process.stdout }) + } + + write(msg: string): void { + this.stream.push(msg) + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/loggers/feishu.logger.ts b/project/aitoearn-monorepo/libs/common/src/loggers/feishu.logger.ts new file mode 100644 index 000000000..a478d6b0e --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/loggers/feishu.logger.ts @@ -0,0 +1,42 @@ +import type { DestinationStream } from 'pino' +import crypto from 'node:crypto' +import { Logger } from '@nestjs/common' + +export interface FeishuOptions { + url: string + secret: string +} +export class FeishuLogger implements DestinationStream { + private readonly logger = new Logger(FeishuLogger.name) + + constructor(private readonly options: FeishuOptions) { + } + + async write(msg: string): Promise { + const timestamp = Math.floor(Date.now() / 1000) + const sign = crypto + .createHmac('sha256', `${timestamp}\n${this.options.secret}`) + .digest() + .toString('base64') + + const content = JSON.parse(msg) + await fetch(this.options.url, { + method: 'POST', + body: JSON.stringify({ + timestamp, + sign, + msg_type: 'text', + content: { + text: JSON.stringify(content, null, 2), + }, + }), + }) + .then(r => r.json()) + .then((r) => { + if (r.code !== 0) { + this.logger.error(r) + } + }) + .catch(e => this.logger.error(121232, e)) + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/loggers/index.ts b/project/aitoearn-monorepo/libs/common/src/loggers/index.ts new file mode 100644 index 000000000..7a6f55a02 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/loggers/index.ts @@ -0,0 +1,3 @@ +export * from './cloud-watch.logger' +export * from './console.logger' +export * from './feishu.logger' diff --git a/project/aitoearn-monorepo/libs/common/src/pipes/index.ts b/project/aitoearn-monorepo/libs/common/src/pipes/index.ts new file mode 100644 index 000000000..e2f7b1d2d --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/pipes/index.ts @@ -0,0 +1 @@ +export * from './zod-validation.pipe' diff --git a/project/aitoearn-monorepo/libs/common/src/pipes/zod-validation.pipe.ts b/project/aitoearn-monorepo/libs/common/src/pipes/zod-validation.pipe.ts new file mode 100644 index 000000000..7449c4441 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/pipes/zod-validation.pipe.ts @@ -0,0 +1,31 @@ +import type { ArgumentMetadata, PipeTransform } from '@nestjs/common' +import type { ZodType } from 'zod' +import type { ZodDto } from '../utils' +import { ValidationPipe } from '@nestjs/common' +import { isZodDto, zodValidate } from '../utils' + +export class ZodValidationPipe extends ValidationPipe implements PipeTransform { + constructor(private schemaOrDto?: ZodType | ZodDto) { + super({ + transform: true, + whitelist: true, + transformOptions: { + excludeExtraneousValues: true, + }, + }) + } + + public override transform(value: unknown, metadata: ArgumentMetadata): Promise { + if (this.schemaOrDto) { + return Promise.resolve(zodValidate(value, this.schemaOrDto)) + } + + const { metatype } = metadata + + if (!isZodDto(metatype)) { + return super.transform(value, metadata) + } + + return Promise.resolve(zodValidate(value, metatype.schema)) + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/starter.ts b/project/aitoearn-monorepo/libs/common/src/starter.ts new file mode 100644 index 000000000..1b9d801c1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/starter.ts @@ -0,0 +1,198 @@ +import type { DynamicModule, Provider, Type } from '@nestjs/common' +import type { NestApplication } from '@nestjs/core' +import type { NestExpressApplication } from '@nestjs/platform-express' +import type { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' +import type { Request, Response } from 'express' +import type { StreamEntry } from 'pino' +import type { BaseConfig } from './config' +import { HttpStatus, Logger, Module } from '@nestjs/common' +import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE, NestFactory } from '@nestjs/core' +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' +import { apiReference } from '@scalar/nestjs-api-reference' +import { LoggerModule, Logger as PinoLogger } from 'nestjs-pino' +import pino from 'pino' +import { z } from 'zod' +import { GlobalExceptionFilter } from './filters' +import { PropagationInterceptor, ResponseInterceptor } from './interceptors' +import { CloudWatchLogger, ConsoleLogger, FeishuLogger } from './loggers' +import { ZodValidationPipe } from './pipes' +import { patchNestJsSwagger, zodToJsonSchemaOptions } from './utils' +import './utils/load-file-from-env.util' + +z.config(z.locales.zhCN()) + +patchNestJsSwagger() + +const logger = new Logger('Bootstrap') + +@Module({}) +class RootModule { + static setup(args: Omit): DynamicModule { + return { + module: RootModule, + ...args, + } + } +} + +export interface StartApplicationOptions { + setupOpenapi?: (builder: DocumentBuilder) => DocumentBuilder + setupApp?: (app: NestApplication) => void +} +export async function startApplication(Module: Type, config: BaseConfig, options: StartApplicationOptions = {}) { + if (config.enableConfigLogging) { + logger.log(JSON.stringify(config, null, 2)) + } + const loggers: StreamEntry[] = [] + + if (config.logger?.console?.enable) { + loggers.push({ + level: config.logger.console.level, + stream: new ConsoleLogger(config.logger.console), + }) + } + + if (config.logger?.cloudWatch?.enable) { + loggers.push({ + level: config.logger.cloudWatch.level, + stream: new CloudWatchLogger(config.logger.cloudWatch), + }) + } + + if (config.logger?.feishu?.enable) { + loggers.push({ + level: config.logger.feishu.level, + stream: new FeishuLogger(config.logger.feishu), + }) + } + + const imports: DynamicModule[] = [ + LoggerModule.forRoot({ + pinoHttp: [ + { + level: 'trace', + }, + pino.multistream(loggers), + ], + }), + ] + const providers: Provider[] = [ + /** + * 传播上下文 + */ + { + provide: APP_INTERCEPTOR, + useClass: PropagationInterceptor, + }, + { + provide: APP_PIPE, + useClass: ZodValidationPipe, + }, + { + provide: APP_INTERCEPTOR, + useClass: ResponseInterceptor, + }, + { + provide: APP_FILTER, + useValue: new GlobalExceptionFilter({ + returnBadRequestDetails: config.enableBadRequestDetails, + }), + }, + ] + + const app = await NestFactory.create< + NestApplication & NestExpressApplication + >(RootModule.setup({ + imports: [...imports, Module], + providers, + }), { + cors: true, + }) + + app.useLogger(app.get(PinoLogger)) + + // setupNatsPattern(app.get(ModulesContainer), new MetadataScanner(), config.nats.prefix) + // setupNatsPattern(app.get(ModulesContainer), new MetadataScanner()) + + // app.connectMicroservice({ + // transport: Transport.NATS, + // options: { + // name: config.nats.name, + // servers: config.nats.servers, + // user: config.nats.user, + // pass: config.nats.pass, + // }, + // }, { + // inheritAppConfig: true, + // }) + + if (config.globalPrefix) + app.setGlobalPrefix(config.globalPrefix, { exclude: ['/'] }) + + if (options.setupApp) { + options.setupApp(app) + } + + if (config.openapi?.enable) { + const builder = new DocumentBuilder() + .setTitle(config.openapi.title) + .setDescription(config.openapi.description) + .setOpenAPIVersion('3.0.0') + + if (options.setupOpenapi) { + options.setupOpenapi(builder) + } + + const openApiDocument = SwaggerModule.createDocument( + app, + builder.build(), + ) + + if (openApiDocument.components?.schemas) { + const zodSchemas = z.toJSONSchema(z.globalRegistry, { ...zodToJsonSchemaOptions, io: 'input' }).schemas + Object.keys(zodSchemas).forEach((key) => { + const schema = zodSchemas[key] + delete schema.$id + }) + openApiDocument.components.schemas = { + ...openApiDocument.components.schemas, + ...zodSchemas as Record, + } + } + app.use( + `${config.openapi.path}/openapi.json`, + (_req: Request, res: Response) => { res.json(openApiDocument) }, + ) + + app.use( + config.openapi.path, + apiReference({ + persistAuth: true, + content: openApiDocument, + }), + ) + } + + app.enableShutdownHooks() + + app.getHttpAdapter().get('/health', (_req, res) => res.status(HttpStatus.OK).send('OK')) + + app.useBodyParser('json', { limit: '50mb' }) + app.useBodyParser('urlencoded', { limit: '50mb', extended: true }) + app.set('query parser', 'extended') + + app.disable('x-powered-by') + + app.enable('trust proxy') + + process.on('uncaughtException', (reason) => { + logger.error(reason) + }) + + await app.startAllMicroservices() + await app.listen(config.port, () => { + logger.log(`app started at port ${config.port}`) + if (config.openapi?.enable) + logger.log(`swagger docs: http://localhost:${config.port}${config.openapi.path}`) + }) +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/exception.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/exception.util.ts new file mode 100755 index 000000000..f907df485 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/exception.util.ts @@ -0,0 +1,76 @@ +import type { CommonResponse } from '../interfaces' +import { BadRequestException, HttpException } from '@nestjs/common' +import { AppException } from '../exceptions/app.exception' + +const MESSAGES = { + UNKNOWN_EXCEPTION_MESSAGE: 'Internal server error', + BAD_REQUEST_MESSAGE: 'Bad request', +} + +export function getExceptionPayload(exception: unknown, returnBadRequestDetails = false): Omit, 'url' | 'timestamp'> { + if (exception instanceof AppException) { + return getPayloadFromAppException(exception) + } + + if (exception instanceof BadRequestException) { + return getPayloadFromBadRequestException(exception, returnBadRequestDetails) + } + + if (exception instanceof HttpException) { + return getPayloadFromHttpException(exception) + } + + return getDefaultPayload() +} + +function getPayloadFromAppException(exception: AppException) { + const response = exception.getResponse() as CommonResponse + + return { + data: response.data || {}, + code: response.code, + message: response.message, + } +} + +function getPayloadFromHttpException(exception: HttpException) { + // eslint-disable-next-line ts/no-explicit-any + const response: any = exception.getResponse() + const code = exception.getStatus() + + const data = {} + + if (typeof response === 'string') { + return { + data, + code, + message: response, + } + } + + return { + data: response.data ?? data, + code: response.code ?? code, + message: response.message ?? MESSAGES.UNKNOWN_EXCEPTION_MESSAGE, + } +} + +function getPayloadFromBadRequestException(exception: BadRequestException, returnBadRequestDetails: boolean) { + if (returnBadRequestDetails) { + return getPayloadFromHttpException(exception) + } + + return { + data: {}, + code: 400, + message: MESSAGES.BAD_REQUEST_MESSAGE, + } +} + +function getDefaultPayload() { + return { + data: {}, + code: 500, + message: MESSAGES.UNKNOWN_EXCEPTION_MESSAGE, + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/get-code-message.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/get-code-message.util.ts new file mode 100644 index 000000000..67b68e655 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/get-code-message.util.ts @@ -0,0 +1,8 @@ +import { ResponseCode } from '../enums/response-code.enum' + +const codeMessageMap: Partial> = { + [ResponseCode.Success]: '请求成功', +} +export function getCodeMessage(code: ResponseCode) { + return codeMessageMap[code] || '未知错误' +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/get-ext-by-mime-type.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/get-ext-by-mime-type.util.ts new file mode 100644 index 000000000..9c2792be7 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/get-ext-by-mime-type.util.ts @@ -0,0 +1,36 @@ +import { AudioType, DocumentType, ImageType, TextType, VideoType } from '../enums' + +export function getExtByMimeType(mimeType: ImageType | DocumentType | VideoType | AudioType | TextType) { + return { + [ImageType.JPEG]: 'jpg', + [ImageType.PNG]: 'png', + [ImageType.GIF]: 'gif', + [ImageType.WEBP]: 'webp', + [ImageType.BMP]: 'bmp', + [ImageType.TIFF]: 'bmp', + [ImageType.ICO]: 'ico', + [ImageType.SVG]: 'svg', + [ImageType.AVIF]: 'avif', + [ImageType.HEIC]: 'heic', + [DocumentType.DOC]: 'doc', + [DocumentType.PDF]: 'pdf', + [DocumentType.DOCX]: 'docx', + [DocumentType.XLS]: 'xls', + [DocumentType.XLSX]: 'xlsx', + [DocumentType.PPT]: 'ppt', + [DocumentType.PPTX]: 'pptx', + [VideoType.AVI]: 'avi', + [VideoType.MP4]: 'mp4', + [VideoType.FLV]: 'flv', + [VideoType.MOV]: 'mov', + [VideoType.WEBM]: 'mkv', + [VideoType.MKV]: 'mkv', + [AudioType.MP3]: 'mp3', + [AudioType.WAV]: 'wav', + [AudioType.AAC]: 'aac', + [AudioType.OGG]: 'ogg', + [AudioType.FLAC]: 'flac', + [TextType.TEXT]: 'txt', + [TextType.CSV]: 'csv', + }[mimeType] +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/index.ts b/project/aitoearn-monorepo/libs/common/src/utils/index.ts new file mode 100644 index 000000000..d01c06f8d --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/index.ts @@ -0,0 +1,9 @@ +export * from './exception.util' +export * from './get-code-message.util' +export * from './get-ext-by-mime-type.util' +export * from './password-generator.util' +export * from './select-config.util' +export * from './setup-nats-pattern.util' +export * from './zod-dto.util' +export * from './zod-openapi.util' +export * from './zod-validate.util' diff --git a/project/aitoearn-monorepo/libs/common/src/utils/load-file-from-env.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/load-file-from-env.util.ts new file mode 100644 index 000000000..11e12720a --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/load-file-from-env.util.ts @@ -0,0 +1,87 @@ +import fs from 'node:fs' +import path from 'node:path' +import { debuglog } from 'node:util' + +const log = debuglog('app:preload-files') + +/** + * 同步地从环境变量中读取配置并创建文件。 + */ +function loadFilesFromEnvSync(): void { + log(`${JSON.stringify(process.env, null, 2)}`) + log('开始同步检查并创建文件...') + + const indexSet = new Set() + for (const key of Object.keys(process.env)) { + const m = key.match(/^WRITE_FILE_(\d+)_PATH$/) + if (m && process.env[key]) { + indexSet.add(Number(m[1])) + } + } + + const indices = Array.from(indexSet).sort((a, b) => a - b) + + if (indices.length === 0) { + log('未找到任何需要创建的文件环境变量 (例如 \'WRITE_FILE_0_PATH\')。') + return + } + + for (const index of indices) { + const prefix = `WRITE_FILE_${index}_` + const filePath = process.env[`${prefix}PATH`] + + if (!filePath) { + log(`[警告] 索引 %d 缺少 PATH,已跳过。`, index) + continue + } + + const encoding = (process.env[`${prefix}ENCODING`] || 'utf8').toLowerCase() + + if (!(['ascii', 'utf8', 'utf-8', 'utf16le', 'utf-16le', 'ucs2', 'ucs-2', 'base64', 'base64url', 'latin1', 'binary', 'hex']).includes(encoding)) { + log(`[警告] 不支持的编码方式 '%s',已跳过文件 '%s'。`, encoding, filePath) + continue + } + let content = process.env[`${prefix}CONTENT`] + if (content === undefined) { + const contentChunks: string[] = [] + let chunkIndex = 0 + while (true) { + const chunk = process.env[`${prefix}CONTENT_${chunkIndex}`] + if (chunk === undefined) { + break + } + contentChunks.push(chunk) + chunkIndex++ + } + + if (contentChunks.length > 0) { + content = contentChunks.join('') + } + } + + if (content === undefined) { + log(`[警告] 找到文件路径 '%s' 但缺少内容 (缺少 %s),已跳过。`, filePath, `${prefix}CONTENT`) + continue + } + + log(`正在处理文件索引 %d: '%s' (编码: %s)`, index, filePath, encoding) + + try { + const parentDir = path.dirname(filePath) + if (parentDir && parentDir !== '.') { + fs.mkdirSync(parentDir, { recursive: true }) + } + + const bufferContent = Buffer.from(content, encoding as BufferEncoding) + fs.writeFileSync(filePath, bufferContent) + + log(` -> 文件 '%s' 已成功创建。`, filePath) + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`[错误] 创建文件 '%s' 时出错: %s`, filePath, message) + } + } +} + +loadFilesFromEnvSync() diff --git a/project/aitoearn-monorepo/libs/common/src/utils/password-generator.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/password-generator.util.ts new file mode 100644 index 000000000..e6a183ce8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/password-generator.util.ts @@ -0,0 +1,13 @@ +import { customAlphabet } from 'nanoid' + +/** + * Generate a secure password using nanoid + * Uses a custom alphabet excluding ambiguous characters (0, O, I, l) + * Default length is 12 characters + */ +export function generateSecurePassword(length = 12): string { + // Custom alphabet excluding ambiguous characters for better readability + const alphabet = 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789' + const nanoidCustom = customAlphabet(alphabet, length) + return nanoidCustom() +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/select-config.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/select-config.util.ts new file mode 100644 index 000000000..f9e2cdb36 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/select-config.util.ts @@ -0,0 +1,30 @@ +import type { ZodDto } from './zod-dto.util' +import { resolve } from 'node:path' +import { program } from 'commander' +import { fileLoader, selectConfig as nestSelectConfig, TypedConfigModule } from 'nest-typed-config' +import { z } from 'zod' +import { zodValidate } from './zod-validate.util' + +export function selectConfig< + TOutput = unknown, + TInput = TOutput, +>(config: ZodDto): TOutput { + const module = TypedConfigModule.forRoot({ + schema: config, + validate(value) { + return zodValidate(value as TInput, config, (error) => { + return new Error(`Configuration is not valid:\n${z.prettifyError(error)}\n`) + }) as Record + }, + load: fileLoader({ + absolutePath: resolve( + process.cwd(), + program + .requiredOption('-c --config ', 'config path') + .parse(process.argv) + .opts()['config'], + ), + }), + }) + return nestSelectConfig(module, config) +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/setup-nats-pattern.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/setup-nats-pattern.util.ts new file mode 100644 index 000000000..dfe914c93 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/setup-nats-pattern.util.ts @@ -0,0 +1,48 @@ +import type { MetadataScanner, ModulesContainer } from '@nestjs/core' +import { PATTERN_METADATA, TRANSPORT_METADATA } from '@nestjs/microservices/constants' +import { Transport } from '@nestjs/microservices/enums/transport.enum' + +export function setupNatsPattern( + container: ModulesContainer, + metadataScanner: MetadataScanner, + prefix?: string, +) { + if (!prefix) { + return + } + + for (const module of container.values()) { + for (const controller of module.controllers.values()) { + const instance = controller.instance + if (!instance) + continue + + const instancePrototype = Object.getPrototypeOf(instance) + const methodNames = metadataScanner.getAllMethodNames(instancePrototype) + + for (const methodName of methodNames) { + const method = instancePrototype[methodName] + if (!method) + continue + + const patterns = Reflect.getMetadata(PATTERN_METADATA, method) + const transport = Reflect.getMetadata(TRANSPORT_METADATA, method) + + if ( + patterns + && Array.isArray(patterns) + && transport === Transport.NATS + ) { + const prefixedPatterns = patterns.map((pattern) => { + if (typeof pattern === 'string') { + return `${prefix}.${pattern}` + } + return pattern + }) + + Reflect.defineMetadata(PATTERN_METADATA, prefixedPatterns, method) + } + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/zod-dto.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/zod-dto.util.ts new file mode 100644 index 000000000..673002b40 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/zod-dto.util.ts @@ -0,0 +1,36 @@ +import { z, ZodType } from 'zod' + +export interface ZodDto< + TOutput = unknown, + TInput = TOutput, +> { + new (): TOutput + isZodDto: true + schema: ZodType + create: (input: TInput) => TOutput +} + +export function createZodDto< + TOutput = unknown, + TInput = TOutput, +>(schema: ZodType, id?: string) { + if (id) + z.globalRegistry.add(schema, { id }) + + class AugmentedZodDto { + public static isZodDto = true + public static schema = schema + + public static create(input: TInput) { + return this.schema.parse(input) + } + } + + return AugmentedZodDto as unknown as ZodDto +} + +export function isZodDto(metatype: unknown): metatype is ZodDto { + return typeof metatype === 'function' + && 'isZodDto' in metatype + && metatype.isZodDto === true +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/zod-openapi.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/zod-openapi.util.ts new file mode 100644 index 000000000..c06b49b52 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/zod-openapi.util.ts @@ -0,0 +1,47 @@ +import type { Type } from '@nestjs/common' +import type { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface' +import { SchemaObjectFactory } from '@nestjs/swagger/dist/services/schema-object-factory' +import { z } from 'zod' +import { isZodDto } from './zod-dto.util' + +export const zodToJsonSchemaOptions: Parameters[1] = { + uri: id => `#/components/schemas/${id}`, + target: 'draft-7', + unrepresentable: 'any', + cycles: 'ref', + override: (ctx) => { + const _zod = ctx.zodSchema._zod + const def = _zod.def + if (def.type === 'date') { + ctx.jsonSchema.type = 'string' + ctx.jsonSchema.format = 'date-time' + } + }, +} + +export function patchNestJsSwagger() { + if ('__patchedWithLoveByNestjsZod' in SchemaObjectFactory.prototype) + return + const defaultExplore = SchemaObjectFactory.prototype.exploreModelSchema + + SchemaObjectFactory.prototype.exploreModelSchema = function ( + this: SchemaObjectFactory | undefined, + type, + schemas, + schemaRefsStack, + ) { + if (this && this['isLazyTypeFunc'](type)) { + const factory = type as () => Type + type = factory() + } + + if (!isZodDto(type)) { + return defaultExplore.call(this, type, schemas, schemaRefsStack) + } + + schemas[type.name] = z.toJSONSchema(type.schema, zodToJsonSchemaOptions) as SchemaObject + return type.name + } + // @ts-expect-error set __patchedWithLoveByNestjsZod to true + SchemaObjectFactory.prototype.__patchedWithLoveByNestjsZod = true +} diff --git a/project/aitoearn-monorepo/libs/common/src/utils/zod-validate.util.ts b/project/aitoearn-monorepo/libs/common/src/utils/zod-validate.util.ts new file mode 100644 index 000000000..bd74090ca --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/utils/zod-validate.util.ts @@ -0,0 +1,24 @@ +import type { ZodType } from 'zod' +import type { ZodExceptionCreator } from '../exceptions' +import type { ZodDto } from './zod-dto.util' +import { createZodValidationException } from '../exceptions' +import { isZodDto } from './zod-dto.util' + +export function zodValidate< + TOutput = unknown, + TInput = TOutput, +>( + value: TInput, + schemaOrDto: ZodType | ZodDto, + createValidationException: ZodExceptionCreator = createZodValidationException, +): TOutput { + const schema: any = isZodDto(schemaOrDto) ? schemaOrDto.schema : schemaOrDto + + const result = schema.safeParse(value) + + if (!result.success) { + throw createValidationException(result.error) + } + + return result.data as TOutput +} diff --git a/project/aitoearn-monorepo/libs/common/src/vos/index.ts b/project/aitoearn-monorepo/libs/common/src/vos/index.ts new file mode 100644 index 000000000..4298eb003 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/vos/index.ts @@ -0,0 +1 @@ +export * from './pagination.vo' diff --git a/project/aitoearn-monorepo/libs/common/src/vos/pagination.vo.ts b/project/aitoearn-monorepo/libs/common/src/vos/pagination.vo.ts new file mode 100644 index 000000000..b56d1dafc --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/src/vos/pagination.vo.ts @@ -0,0 +1,36 @@ +import z from 'zod' +import { Pagination } from '../dtos' +import { createZodDto } from '../utils' + +export function createPaginationVo(dataSchema: z.ZodType, id?: string) { + const PaginationVoSchema = z.object({ + page: z.number().int(), + pageSize: z.number().int(), + totalPages: z.number().int(), + total: z.number().int(), + list: z.array(dataSchema), + }) + + class PaginationVo extends createZodDto(PaginationVoSchema, id) { + constructor(list: T[], total: number, pagination: Pagination) { + super() + Object.assign(this, PaginationVo.create({ + page: pagination.page, + pageSize: pagination.pageSize, + totalPages: Math.ceil(total / pagination.pageSize), + total, + list, + })) + } + } + + return PaginationVo +} + +export interface PaginationVo { + page: number + pageSize: number + totalPages: number + total: number + list: T[] +} diff --git a/project/aitoearn-monorepo/libs/common/tsconfig.json b/project/aitoearn-monorepo/libs/common/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/common/tsconfig.lib.json b/project/aitoearn-monorepo/libs/common/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/common/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/mail/README.md b/project/aitoearn-monorepo/libs/mail/README.md new file mode 100644 index 000000000..5494867f7 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/README.md @@ -0,0 +1,7 @@ +# mail + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build mail` to build the library. diff --git a/project/aitoearn-monorepo/libs/mail/eslint.config.mjs b/project/aitoearn-monorepo/libs/mail/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/mail/package.json b/project/aitoearn-monorepo/libs/mail/package.json new file mode 100644 index 000000000..6a51028b8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/package.json @@ -0,0 +1,24 @@ +{ + "name": "@yikart/mail", + "type": "commonjs", + "version": "0.0.2", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/common": "^11.0.0", + "@yikart/common": "*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "ioredis": "^5.7.0" + } +} diff --git a/project/aitoearn-monorepo/libs/mail/project.json b/project/aitoearn-monorepo/libs/mail/project.json new file mode 100644 index 000000000..a8106e2f0 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/project.json @@ -0,0 +1,42 @@ +{ + "name": "mail", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/mail/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/mail", + "tsConfig": "libs/mail/tsconfig.lib.json", + "packageJson": "libs/mail/package.json", + "main": "libs/mail/src/index.ts", + "assets": ["libs/mail/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/mail/src/index.ts b/project/aitoearn-monorepo/libs/mail/src/index.ts new file mode 100644 index 000000000..5b53d7c57 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/src/index.ts @@ -0,0 +1,2 @@ +export * from './mail.module' +export * from './mail.service' diff --git a/project/aitoearn-monorepo/libs/mail/src/mail.config.ts b/project/aitoearn-monorepo/libs/mail/src/mail.config.ts new file mode 100644 index 000000000..825818bce --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/src/mail.config.ts @@ -0,0 +1,20 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const mailConfigSchema = z.object({ + transport: z.object({ + host: z.string().default(''), + port: z.number().default(587), + secure: z.boolean().default(false), + auth: z.object({ + user: z.string().default(''), + pass: z.string().default(''), + }), + }), + defaults: z.object({ + from: z.string().default(''), + }), + template: z.any().optional(), +}) + +export class MailConfig extends createZodDto(mailConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/mail/src/mail.constant.ts b/project/aitoearn-monorepo/libs/mail/src/mail.constant.ts new file mode 100644 index 000000000..bcd7ad182 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/src/mail.constant.ts @@ -0,0 +1,9 @@ +/* + * @Author: nevin + * @Date: 2022-10-29 22:19:30 + * @LastEditTime: 2024-08-31 18:55:18 + * @LastEditors: nevin + * @Description: + */ +export const MAIL_CLIENT_EN = 'MAIL_CLIENT_EN' +export const MAIL_CLIENT_CN = 'MAIL_CLIENT_CN' diff --git a/project/aitoearn-monorepo/libs/mail/src/mail.module.ts b/project/aitoearn-monorepo/libs/mail/src/mail.module.ts new file mode 100644 index 000000000..e405f5e78 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/src/mail.module.ts @@ -0,0 +1,32 @@ +import { MailerModule } from '@nestjs-modules/mailer' +import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter' +import { DynamicModule, Global, Module } from '@nestjs/common' +import { MailConfig } from './mail.config' +import { MailService } from './mail.service' + +@Global() +@Module({}) +export class MailModule { + static forRoot(config: MailConfig): DynamicModule { + return { + module: MailModule, + imports: [ + MailerModule.forRoot({ + ...config, + template: { + dir: config.template.dir, + adapter: new HandlebarsAdapter(), + options: { + strict: true, + }, + }, + }), + ], + providers: [ + { provide: MailConfig, useValue: config }, + MailService, + ], + exports: [MailService], + } + } +} diff --git a/project/aitoearn-monorepo/libs/mail/src/mail.service.ts b/project/aitoearn-monorepo/libs/mail/src/mail.service.ts new file mode 100644 index 000000000..17220ff7e --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/src/mail.service.ts @@ -0,0 +1,26 @@ +/* + * @Author: nevin + * @Date: 2024-06-11 10:27:06 + * @LastEditTime: 2024-07-05 15:50:12 + * @LastEditors: nevin + * @Description: + */ +import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer' +import { Injectable, Logger } from '@nestjs/common' + +@Injectable() +export class MailService { + logger = new Logger(MailService.name) + constructor(private readonly mailerService: MailerService) {} + + async sendEmail(p: ISendMailOptions): Promise { + try { + const res = await this.mailerService.sendMail(p) + return !!res + } + catch (error) { + this.logger.error(error) + return false + } + } +} diff --git a/project/aitoearn-monorepo/libs/mail/tsconfig.json b/project/aitoearn-monorepo/libs/mail/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/mail/tsconfig.lib.json b/project/aitoearn-monorepo/libs/mail/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mail/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/mongodb/README.md b/project/aitoearn-monorepo/libs/mongodb/README.md new file mode 100644 index 000000000..f1f5e1e11 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/README.md @@ -0,0 +1,7 @@ +# mongodb + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build mongodb` to build the library. diff --git a/project/aitoearn-monorepo/libs/mongodb/eslint.config.mjs b/project/aitoearn-monorepo/libs/mongodb/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/mongodb/package.json b/project/aitoearn-monorepo/libs/mongodb/package.json new file mode 100644 index 000000000..27c4a0ccf --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/package.json @@ -0,0 +1,23 @@ +{ + "name": "@yikart/mongodb", + "type": "commonjs", + "version": "0.0.2", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/mongoose": "^11.0.3", + "@yikart/common": "workspace:*", + "@yikart/stripe": "workspace:*", + "dayjs": "^1.11.14", + "lodash": "^4.17.21", + "mongodb": "~6.20.0", + "mongoose": "^8.18.0", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/lodash": "^4.17.20" + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/project.json b/project/aitoearn-monorepo/libs/mongodb/project.json new file mode 100644 index 000000000..39a1b175b --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/project.json @@ -0,0 +1,36 @@ +{ + "name": "mongodb", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/mongodb/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/mongodb", + "tsConfig": "libs/mongodb/tsconfig.lib.json", + "packageJson": "libs/mongodb/package.json", + "main": "libs/mongodb/src/index.ts", + "assets": ["libs/mongodb/*.md"] + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/decorators/transactional.decorator.ts b/project/aitoearn-monorepo/libs/mongodb/src/decorators/transactional.decorator.ts new file mode 100644 index 000000000..1a888f6e1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/decorators/transactional.decorator.ts @@ -0,0 +1,28 @@ +import { SetMetadata } from '@nestjs/common' +import { TransactionOptions } from 'mongodb' + +export const TRANSACTIONAL_METADATA = Symbol('TRANSACTIONAL_METADATA') + +/** + * MongoDB 事务装饰器 + * 用于自动管理 MongoDB 事务的开始、提交和回滚 + * + * @param options 事务配置选项 + * @returns MethodDecorator + * + * @example + * ```typescript + * class UserService { + * @Transactional() + * async createUserWithProfile(userData: CreateUserDto, profileData: CreateProfileDto) { + * // 这里的所有数据库操作都会在同一个事务中执行 + * const user = await this.userRepository.create(userData) + * const profile = await this.profileRepository.create({ ...profileData, userId: user._id }) + * return { user, profile } + * } + * } + * ``` + */ +export function Transactional(options?: TransactionOptions): MethodDecorator { + return SetMetadata(TRANSACTIONAL_METADATA, options) +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/ai-log.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/ai-log.enum.ts new file mode 100644 index 000000000..5c279eb81 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/ai-log.enum.ts @@ -0,0 +1,22 @@ +export enum AiLogType { + Chat = 'chat', + Image = 'image', + Card = 'card', + Video = 'video', +} + +export enum AiLogStatus { + Generating = 'generating', + Success = 'success', + Failed = 'failed', +} + +export enum AiLogChannel { + NewApi = 'new-api', + Md2Card = 'md2card', + FireflyCard = 'fireflyCard', + Kling = 'kling', + Volcengine = 'volcengine', + Dashscope = 'dashscope', + Sora2 = 'sora2', +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/app-release.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/app-release.enum.ts new file mode 100644 index 000000000..82150b3cb --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/app-release.enum.ts @@ -0,0 +1,6 @@ +export enum AppPlatform { + Android = 'android', + IOS = 'ios', + Windows = 'windows', + MacOS = 'macos', +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/cloud-space.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/cloud-space.enum.ts new file mode 100644 index 000000000..baf68471a --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/cloud-space.enum.ts @@ -0,0 +1,16 @@ +export enum CloudSpaceStatus { + Creating = 'creating', + Configuring = 'configuring', + Ready = 'ready', + Error = 'error', + Terminated = 'terminated', +} + +export enum CloudSpaceRegion { + Washington = 'us-ws', + LosAngeles = 'us-ca', + London = 'uk-london', + Singapore = 'sg', + Tokyo = 'jpn-tky', + Hongkong = 'hk', +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/feedback.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/feedback.enum.ts new file mode 100644 index 000000000..c0ba26701 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/feedback.enum.ts @@ -0,0 +1,6 @@ +export enum FeedbackType { + errReport = 'errReport', // 错误反馈 + feedback = 'feedback', // 反馈 + msgReport = 'msgReport', // 消息举报 + msgFeedback = 'msgFeedback', // 消息反馈 +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/income-record.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/income-record.enum.ts new file mode 100644 index 000000000..87ce87efb --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/income-record.enum.ts @@ -0,0 +1,12 @@ +export enum IncomeType { + Task = 'task', // 任务 + TaskWithdraw = 'task_withdraw', // 任务提现扣除 + TaskBack = 'task_back', // 任务回退 + RewardBack = 'reward_back', // 奖励回退 +} + +// 提现状态 +export enum IncomeStatus { + WAIT = 0, // 待提现 + DO = 1, // 已经提现 +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/index.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/index.ts new file mode 100644 index 000000000..dfdcedf8f --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/index.ts @@ -0,0 +1,10 @@ +export * from './ai-log.enum' +export * from './app-release.enum' +export * from './cloud-space.enum' +export * from './feedback.enum' +export * from './income-record.enum' +export * from './notification.enum' +export * from './publish.enum' +export * from './user-wallet-account.enum' +export * from './user.enum' +export * from './withdraw-record.enum' diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/notification.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/notification.enum.ts new file mode 100644 index 000000000..72bd852b3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/notification.enum.ts @@ -0,0 +1,10 @@ +// 通知状态枚举 +export enum NotificationStatus { + Unread = 'unread', + Read = 'read', +} + +// 通知类型枚举 +export enum NotificationType { + TaskReminder = 'task_reminder', // 任务提醒 +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/publish.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/publish.enum.ts new file mode 100644 index 000000000..6b769c257 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/publish.enum.ts @@ -0,0 +1,11 @@ +export enum PublishType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', +} + +export enum PublishStatus { + FAILED = -1, // 发布失败 + WaitingForPublish = 0, // 未发布 + PUBLISHED = 1, // 已发布 + PUBLISHING = 2, // 发布中 +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/user-wallet-account.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/user-wallet-account.enum.ts new file mode 100644 index 000000000..93c6619fe --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/user-wallet-account.enum.ts @@ -0,0 +1,4 @@ +export enum WalletAccountType { + Alipay = 'ZFB', + WechatPay = 'WX_PAY', +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/user.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/user.enum.ts new file mode 100644 index 000000000..cc4cd66ef --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/user.enum.ts @@ -0,0 +1,38 @@ +export enum UserStatus { + STOP = 0, + OPEN = 1, +} + +export enum EarnInfoStatus { + CLOSE = 0, + OPEN = 1, +} + +export enum GenderEnum { + MALE = 1, // 男 + FEMALE = 2, // 女 +} + +export enum VipStatus { + none = 'none', // 无会员 + expired = 'expired', // 过期 + trialing = 'trialing', // 试用中 + monthly_once = 'monthly_once', // 包月一次性 + yearly_once = 'yearly_once', // 包年一次性 + active_monthly = 'active_monthly', // 连续包月中 + active_yearly = 'active_yearly', // 连续包年中 + active_nonrenewing = 'active_nonrenewing', // 有效(未续订) + // pending_payment = 'pending_payment', // 支付中 + // unpaid = 'unpaid', // 支付失败 + // canceled = 'canceled', // 取消 +} + +// 有效会员状态数组 +export const VipActiveStatusArr = [ + VipStatus.trialing, + VipStatus.monthly_once, + VipStatus.yearly_once, + VipStatus.active_monthly, + VipStatus.active_yearly, + VipStatus.active_nonrenewing, +] diff --git a/project/aitoearn-monorepo/libs/mongodb/src/enums/withdraw-record.enum.ts b/project/aitoearn-monorepo/libs/mongodb/src/enums/withdraw-record.enum.ts new file mode 100644 index 000000000..6488b65ae --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/enums/withdraw-record.enum.ts @@ -0,0 +1,10 @@ +export enum WithdrawRecordType { + Task = 'task', // 任务 + Reward = 'reward', // 奖励 +} + +export enum WithdrawRecordStatus { + WAIT = 0, + SUCCESS = 1, + FAIL = -1, +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/index.ts b/project/aitoearn-monorepo/libs/mongodb/src/index.ts new file mode 100644 index 000000000..0a58db95d --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/index.ts @@ -0,0 +1,6 @@ +export * from './decorators/transactional.decorator' +export * from './enums' +export * from './mongodb.config' +export * from './mongodb.module' +export * from './repositories' +export * from './schemas' diff --git a/project/aitoearn-monorepo/libs/mongodb/src/mongodb.config.ts b/project/aitoearn-monorepo/libs/mongodb/src/mongodb.config.ts new file mode 100644 index 000000000..22e2eea9c --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/mongodb.config.ts @@ -0,0 +1,11 @@ +import { createZodDto } from '@yikart/common' +import { z } from 'zod' + +export const mongodbConfigSchema = z.object({ + uri: z.string(), + dbName: z.string().optional(), + autoIndex: z.boolean().optional(), + autoCreate: z.boolean().optional(), +}) + +export class MongodbConfig extends createZodDto(mongodbConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/mongodb.module.ts b/project/aitoearn-monorepo/libs/mongodb/src/mongodb.module.ts new file mode 100644 index 000000000..02824bdd4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/mongodb.module.ts @@ -0,0 +1,41 @@ +import type { MongodbConfig } from './mongodb.config' +/* + * @Author: nevin + * @Date: 2022-09-23 18:00:51 + * @LastEditTime: 2025-01-15 14:20:46 + * @LastEditors: nevin + * @Description: + */ +import { Global } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' +import mongoose from 'mongoose' +import { repositories } from './repositories' +import { schemas } from './schemas' +import { TransactionalInjector } from './transactional.injector' + +mongoose.set('transactionAsyncLocalStorage', true) + +@Global() +export class MongodbModule { + static forRoot(config: MongodbConfig) { + const forFeature = MongooseModule.forFeature([...schemas]) + const { uri, ...options } = config + + return { + imports: [ + MongooseModule.forRoot(uri, options), + forFeature, + ], + providers: [ + ...repositories, + TransactionalInjector, + ], + exports: [ + forFeature, + ...repositories, + ], + module: MongodbModule, + global: true, + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/account.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/account.repository.ts new file mode 100644 index 000000000..ebceea8f1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/account.repository.ts @@ -0,0 +1,310 @@ +import { InjectModel } from '@nestjs/mongoose' +import { AccountType, AppException, TableDto } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { Account, AccountStatus } from '../schemas' +import { BaseRepository } from './base.repository' + +export class AccountRepository extends BaseRepository { + constructor( + @InjectModel(Account.name) + private readonly accountModel: Model, + ) { super(accountModel) } + + /** + * 将用户组下的账户切换到默认组 + * @param userId + * @param groupId + * @param defaultGroupId + */ + async switchToDefaultGroup( + userId: string, + groupId: string, + defaultGroupId: string, + ) { + return this.accountModel.updateMany( + { userId, groupId }, + { groupId: defaultGroupId }, + ) + } + + async addAccount(data: Partial): Promise { + const info: Account | null = await this.accountModel.findOne({ + type: data.type, + uid: data.uid, + }) + + if (info) { + await this.accountModel.updateOne({ + type: data.type, + uid: data.uid, + }, { + ...data, + status: AccountStatus.NORMAL, + loginTime: new Date(), + }) + } + else { + data['_id'] = `${data.type}_${data.uid}` + await this.accountModel.create({ ...data }) + } + + const backInfo = await this.accountModel.findOne({ + type: data.type, + uid: data.uid, + }) + + if (!backInfo) + throw new AppException(1000, 'addAccount fail') + return backInfo + } + + /** + * 更新账号信息 + * @param id + * @param account + * @returns + */ + async updateAccountInfoById( + id: string, + account: Partial, + ): Promise { + await this.accountModel.updateOne( + { _id: id }, + { $set: account }, + ) + + const info = await this.getAccountById(id) + return info + } + + /** + * 根据用户id获取账号 + */ + async getAccountById(id: string) { + return this.accountModel.findOne({ _id: id }).exec() + } + + /** + * 获取所有账户 + * @param userId + * @returns + */ + async getUserAccounts(userId: string) { + const accounts = await this.accountModel.find({ + userId, + }) + if (!accounts || accounts.length === 0) { + return [] + } + return accounts + } + + async getAccounts(filterDto: { + userId?: string + types?: string[] + }, pageInfo: TableDto) { + const { pageNo, pageSize } = pageInfo + const filter: RootFilterQuery = { + } + if (filterDto.userId) { + filter.userId = filterDto.userId + } + + if (filterDto.types) { + filter.type = { + $in: filterDto.types, + } + } + + const total = await this.accountModel.countDocuments(filter) + const list = await this.accountModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean() + + return { + total, + list, + } + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param userId + * @param ids + * @returns + */ + async getAccountListByIdsOfUser(userId: string, ids: string[]) { + return this.accountModel.find({ + userId, + id: { $in: ids }, + }) + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param userId + * @param ids + * @returns + */ + async getAccountListByIds(ids: string[]) { + return this.accountModel.find({ + id: { $in: ids }, + }) + } + + async getAccountStatistics( + userId: string, + type?: AccountType, + ): Promise<{ + accountTotal: number + list: Account[] + fansCount?: number + readCount?: number + likeCount?: number + collectCount?: number + commentCount?: number + income?: number + }> { + const accountList = await this.accountModel.find({ + userId, + ...(type && { type }), + }) + + const res = { + accountTotal: accountList.length, + list: accountList, + fansCount: 0, + } + + return res + } + + async getUserAccountCount(userId: string) { + return await this.accountModel.countDocuments({ userId }) + } + + /** + * 根据多个账户id查询账户信息 + * @param ids + * @returns + */ + async getAccountsByIds(ids: string[]) { + return await this.accountModel.find({ + id: { $in: ids }, + }) + } + + /** + * 删除 + * @param id + * @param userId + * @returns + */ + async deleteUserAccount(id: string, userId: string): Promise { + const res = await this.accountModel.deleteOne({ + _id: id, + userId, + }) + + return res.deletedCount > 0 + } + + async deleteUserAccounts(ids: string[], userId: string) { + const res = await this.accountModel.deleteMany({ + _id: { $in: ids }, + userId, + }) + return res.deletedCount > 0 + } + + /** + * 更新用户状态 + * @param id + * @param status + * @returns + */ + async updateAccountStatus(id: string, status: AccountStatus) { + const res = await this.accountModel.updateOne({ _id: id }, { status }) + return res + } + + async updateAccountStatistics( + id: string, + data: { + fansCount?: number + readCount?: number + likeCount?: number + collectCount?: number + commentCount?: number + income?: number + workCount?: number + }, + ) { + const res = await this.accountModel.updateOne( + { _id: id }, + { + $set: data, + }, + ) + return res.matchedCount > 0 || res.modifiedCount > 0 + } + + async getAccountByParam(param: { [key: string]: string }) { + return await this.accountModel.findOne(param) + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param ids + * @returns + */ + async listByIds(ids: string[]) { + return this.accountModel.find({ + _id: { $in: ids }, + }) + } + + /** + * 根据空间ID数组spaceIds获取账户列表数组 + * @param spaceIds + * @returns + */ + async listBySpaceIds(userId: string, spaceIds: string[]) { + return this.accountModel.find({ + userId, + groupId: { $in: spaceIds }, + }) + } + + /** + * 根据type数组获取所有账户 + * @param types + * @param status + * @returns + */ + async getAccountsByTypes(types: string[], status?: number) { + const filter: RootFilterQuery = {} + filter.type = { + $in: types, + } + if (status) { + filter.status = status + } + + const accounts = await this.accountModel + .find(filter) + + return accounts + } + + async sortRank(userId: string, groupId: string, list: { id: string, rank: number }[]) { + const promises = list.map(element => + this.accountModel.updateOne({ userId, groupId, _id: element.id }, { $set: { rank: element.rank } }), + ) + await Promise.all(promises) + return true + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/accountGroup.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/accountGroup.repository.ts new file mode 100644 index 000000000..86b6f32f8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/accountGroup.repository.ts @@ -0,0 +1,120 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { AccountGroup } from '../schemas/accountGroup.schema' +import { BaseRepository } from './base.repository' + +export class AccountGroupRepository extends BaseRepository { + constructor( + @InjectModel(AccountGroup.name) + private readonly accountGroupModel: Model, + ) { super(accountGroupModel) } + + async findOneById(id: string) { + return this.accountGroupModel.findOne({ _id: id }).exec() + } + + // 获取默认用户组, 没有则创建 + async getDefaultGroup(userId: string): Promise { + const data = await this.accountGroupModel + .findOne({ + userId, + isDefault: true, + }) + .exec() + + if (data) + return data + + // 创建 + return await this.createAccountGroup({ + isDefault: true, + name: 'default', + rank: 1, + userId, + }) + } + + /** + * 添加组 + * @param accountGroup + */ + async createAccountGroup( + accountGroup: Partial, + ): Promise { + return this.accountGroupModel.create(accountGroup) + } + + /** + * 更新组 + * @param accountGroup + */ + async updateAccountGroup( + id: string, + accountGroup: Partial, + ): Promise { + const res = await this.accountGroupModel.findByIdAndUpdate( + id, + { $set: accountGroup }, + ) + return !!res + } + + /** + * 更新组 + * @param accountGroup + */ + async getAccountGorupListByIds( + ids: string[], + userId: string, + ) { + const accountGorupList = await this.accountGroupModel + .find({ userId, _id: { $in: ids } }) + .exec() + return accountGorupList + } + + /** + * 删除多个组 + * @param ids + * @param userId + */ + async deleteAccountGroup(ids: string[], userId: string): Promise { + const res = await this.accountGroupModel.deleteMany({ + _id: { $in: ids }, + userId, + }) + return res.deletedCount > 0 + } + + /** + * 获取所有组 + * @param userId + * @returns + */ + async getAccountGroup(userId: string): Promise { + const accountGroupList: AccountGroup[] = await this.accountGroupModel.find({ + userId, + }) + + // 创建默认用户组 + if (accountGroupList.length === 0) { + const accountGroup = await this.getDefaultGroup(userId) + accountGroupList.push(accountGroup) + } + + return accountGroupList + } + + // 排序 + async sortRank(userId: string, list: { id: string, rank: number }[]) { + const promises = list.map(element => + this.accountGroupModel.updateOne( + { userId, _id: element.id }, + { $set: { rank: element.rank } }, + ), + ) + + await Promise.all(promises) + return true + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-account.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-account.repository.ts new file mode 100644 index 000000000..8c7de9472 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-account.repository.ts @@ -0,0 +1,51 @@ +import { InjectModel } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { Account, AccountStatus } from '../../schemas' +import { BaseRepository } from '../base.repository' + +export class AdminAccountRepository extends BaseRepository { + constructor( + @InjectModel(Account.name) + private readonly accountModel: Model, + ) { super(accountModel) } + + async getAccountById(id: string) { + return this.accountModel.findOne({ _id: id }).exec() + } + + /** + * 获取所有账户 + * @param filterDto + * @param pageInfo + * @returns + */ + async getAccountList(inFilter: { + userId?: string + status?: AccountStatus + types?: AccountType[] + }, pageInfo: { + pageNo: number + pageSize: number + }) { + const { pageNo, pageSize } = pageInfo + const filter: RootFilterQuery = { + ...(inFilter.userId && { userId: inFilter.userId }), + ...(inFilter.status !== undefined && { status: inFilter.status }), + ...(inFilter.types && { type: { $in: inFilter.types } }), + } + + const total = await this.accountModel.countDocuments(filter) + const list = await this.accountModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean() + + return { + total, + list, + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-user.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-user.repository.ts new file mode 100644 index 000000000..919cd722d --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/admin-user.repository.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model, RootFilterQuery } from 'mongoose' +import { UserStatus } from '../../enums' +import { User } from '../../schemas' +import { BaseRepository } from '../base.repository' + +@Injectable() +export class AdminUserRepository extends BaseRepository { + constructor( + @InjectModel(User.name) + private readonly userModel: Model, + ) { + super(userModel) + } + + async getUserInfoById(id: string, all = false) { + let userInfo + try { + const db = this.userModel.findById(id) + if (all) + db.select('+password +salt') + userInfo = await db.exec() + } + catch { + return null + } + return userInfo + } + + async list(pageInfo: { + pageSize: number + pageNo: number + }, query: { + keyword?: string + status?: UserStatus + time?: string[] + }) { + const { pageSize, pageNo } = pageInfo + const { keyword, status } = query + const filter: RootFilterQuery = { + ...(status !== undefined && { status }), + ...(keyword !== undefined && { + $or: [ + { name: { $regex: keyword, $options: 'i' } }, + { mail: { $regex: keyword, $options: 'i' } }, + ], + }), + ...(query.time && { + createdAt: { + $gte: query.time[0], + $lte: query.time[1], + }, + }), + } + + const list = await this.userModel + .find(filter) + .sort({ createdAt: -1 }) + .skip(pageNo! > 0 ? (pageNo! - 1) * pageSize : 0) + .limit(pageSize) + .exec() + + return { + list, + total: await this.userModel.countDocuments(filter), + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/manager.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/manager.repository.ts new file mode 100644 index 000000000..24f6006ac --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/admin/manager.repository.ts @@ -0,0 +1,72 @@ +import { Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { Manager } from '../../schemas' +import { BaseRepository } from '../base.repository' + +export class ManagerRepository extends BaseRepository { + logger = new Logger(ManagerRepository.name) + constructor( + @InjectModel(Manager.name) private readonly managerModel: Model, + ) { super(managerModel) } + + async getInfoById(id: string, all = false) { + let userInfo + if (userInfo) + return userInfo + try { + const db = this.managerModel.findById(id) + if (all) + db.select('+password +salt') + userInfo = await db.exec() + } + catch (error) { + this.logger.error(error) + return null + } + return userInfo + } + + /** + * 创建管理员 + * @param data + * @returns + */ + async createByAccount(data: Partial): Promise { + const res = await this.managerModel.create(data) + return res + } + + async getInfoByAccount(account: string) { + const info = await this.managerModel.findOne({ + account, + }) + return info + } + + /** + * 更新用户密码 + * @param mail + * @param password + * @returns + */ + async updateUserPassword( + id: string, + newData: { + password: string + salt: string + }, + ): Promise<0 | 1> { + const res = await this.managerModel.updateOne( + { _id: id }, + { + $set: { + password: newData.password, + salt: newData.salt, + }, + }, + ) + + return res.modifiedCount > 0 ? 1 : 0 + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/ai-log.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/ai-log.repository.ts new file mode 100644 index 000000000..83947b381 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/ai-log.repository.ts @@ -0,0 +1,95 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination, RangeFilter, UserType } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { AiLogStatus, AiLogType } from '../enums' +import { AiLog } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListAiLogParams extends Pagination { + userId?: string + userType?: UserType + type?: AiLogType + status?: AiLogStatus + model?: string + createdAt?: RangeFilter +} + +export interface ListAiLogFilter { + userId?: string + userType?: UserType + type?: AiLogType + status?: AiLogStatus + model?: string + createdAt?: RangeFilter +} + +export class AiLogRepository extends BaseRepository { + constructor( + @InjectModel(AiLog.name) aiLogModel: Model, + ) { + super(aiLogModel) + } + + async listWithPagination(params: ListAiLogParams) { + const { page, pageSize, userId, userType, type, status, model, createdAt } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (userType) + filter.userType = userType + if (type) + filter.type = type + if (status) + filter.status = status + if (model) + filter.model = model + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async list(params: ListAiLogFilter) { + const { userId, userType, type, status, model, createdAt } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (userType) + filter.userType = userType + if (type) + filter.type = type + if (status) + filter.status = status + if (model) + filter.model = model + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + async getByTaskId(taskId: string) { + return await this.findOne({ taskId }) + } + + async getByIdAndUserId(id: string, userId: string, userType: UserType) { + return await this.findOne({ _id: id, userId, userType }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-config.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-config.repository.ts new file mode 100644 index 000000000..141d30b7e --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-config.repository.ts @@ -0,0 +1,191 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model, RootFilterQuery } from 'mongoose' +import { AppConfig } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListAppConfigParams extends Pagination { + appId?: string + key?: string + enabled?: boolean + keyword?: string +} + +export class AppConfigRepository extends BaseRepository { + constructor( + @InjectModel(AppConfig.name) appConfigModel: Model, + ) { + super(appConfigModel) + } + + async getConfig(appId: string): Promise> { + const configs = await this.model.find({ + appId, + enabled: true, + }).exec() + + return configs + } + + async getConfigHistory(appId: string, key: string, limit = 10): Promise { + return await this.model.find({ appId, key }) + .sort({ updatedAt: -1 }) + .limit(limit) + .exec() + } + + async updateConfig( + appId: string, + key: string, + value: any, + description?: string, + metadata?: Record, + ): Promise { + const valueStr = typeof value === 'string' ? value : JSON.stringify(value) + + const updatedConfig = await this.model.findOneAndUpdate( + { appId, key }, + { + $set: { + value: valueStr, + description, + metadata, + enabled: true, + }, + }, + { upsert: true, new: true }, + ).exec() + + return updatedConfig + } + + async batchUpdateConfigs( + appId: string, + configs: Record, + ): Promise<{ success: boolean, updatedCount: number }> { + const bulkOps = Object.entries(configs).map(([key, value]) => { + const valueStr = typeof value === 'string' ? value : JSON.stringify(value) + return { + updateOne: { + filter: { appId, key }, + update: { + $set: { + value: valueStr, + enabled: true, + }, + }, + upsert: true, + }, + } + }) + + const result = await this.model.bulkWrite(bulkOps) + return { + success: true, + updatedCount: result.modifiedCount + result.upsertedCount, + } + } + + async deleteConfig(appId: string, key: string): Promise { + const result = await this.model.deleteOne({ appId, key }).exec() + return result.deletedCount > 0 + } + + async getConfigList( + page: { + pageNo: number + pageSize: number + }, + query: { + appId?: string + key?: string + }, + ) { + const filter: RootFilterQuery = { + ...(query.appId && { appId: query.appId }), + ...(query.key && { key: query.key }), + } + const total = await this.model.countDocuments(filter).exec() + const result = await this.model.find(filter).skip((page.pageNo - 1) * page.pageSize).limit(page.pageSize).exec() + + return { total, list: result } + } + + async listWithPagination(params: ListAppConfigParams) { + const { page, pageSize, appId, key, enabled, keyword } = params + + const filter: FilterQuery = {} + if (appId) + filter.appId = appId + if (key) + filter.key = key + if (enabled !== undefined) + filter.enabled = enabled + if (keyword) { + filter.$or = [ + { key: { $regex: keyword, $options: 'i' } }, + { description: { $regex: keyword, $options: 'i' } }, + ] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + }) + } + + async listByAppIdWithEnabled(appId: string) { + return await this.find({ + appId, + enabled: true, + }) + } + + async listByAppIdAndKey(appId: string, key: string, limit = 10) { + return await this.find( + { appId, key }, + { sort: { updatedAt: -1 }, limit }, + ) + } + + async upsertByAppIdAndKey( + appId: string, + key: string, + updateData: Partial, + ) { + return await this.model.findOneAndUpdate( + { appId, key }, + { $set: updateData }, + { upsert: true, new: true }, + ).exec() + } + + async bulkUpsert(configEntries: Array<{ + appId: string + key: string + value: string + enabled: boolean + }>) { + const bulkOps = configEntries.map(entry => ({ + updateOne: { + filter: { appId: entry.appId, key: entry.key }, + update: { + $set: { + value: entry.value, + enabled: entry.enabled, + }, + }, + upsert: true, + }, + })) + + const result = await this.model.bulkWrite(bulkOps) + return result.modifiedCount + result.upsertedCount + } + + async deleteByAppIdAndKey(appId: string, key: string) { + const result = await this.deleteOne({ appId, key }) + return result.deletedCount + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-release.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-release.repository.ts new file mode 100644 index 000000000..a377a3018 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/app-release.repository.ts @@ -0,0 +1,63 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { AppPlatform } from '../enums' +import { AppRelease } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListAppReleaseParams extends Pagination { + platform?: AppPlatform +} + +export class AppReleaseRepository extends BaseRepository { + constructor( + @InjectModel(AppRelease.name) model: Model, + ) { + super(model) + } + + async listWithPagination(params: ListAppReleaseParams) { + const { page, pageSize, platform } = params + + const filter: FilterQuery = {} + if (platform) { + filter.platform = platform + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { buildNumber: -1 } }, + }) + } + + async getByPlatformAndVersion(platform: AppPlatform, version: string) { + return await this.model.findOne({ + platform, + version, + }).exec() + } + + async getLatestByPlatform(platform: AppPlatform, options?: { + forceUpdate?: boolean + }) { + return await this.model.findOne({ + platform, + ...options, + }).sort({ buildNumber: -1 }).exec() + } + + async checkExistsByPlatformAndBuildNumber(platform: AppPlatform, buildNumber: number, excludeId?: string) { + const filter: FilterQuery = { + platform, + buildNumber, + } + + if (excludeId) { + filter._id = { $ne: excludeId } + } + + return await this.exists(filter) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/base.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/base.repository.ts new file mode 100644 index 000000000..03c95babe --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/base.repository.ts @@ -0,0 +1,129 @@ +import { Pagination } from '@yikart/common' +import { DeleteOptions } from 'mongodb' +import { FilterQuery, Model, MongooseBaseQueryOptions, QueryOptions, UpdateQuery } from 'mongoose' + +export interface PaginationParams extends Pagination { + filter?: FilterQuery + options?: QueryOptions +} + +export type CreateDocumentType = Partial + +export type UpdateDocumentType = UpdateQuery + +export class BaseRepository { + constructor( + protected readonly model: Model, + ) {} + + /** + * 根据ID获取单个文档 + */ + async getById(id: string, options?: QueryOptions) { + return await this.model.findById(id, undefined, options).exec() + } + + /** + * 创建新文档 + */ + async create(data: CreateDocumentType) { + const created = new this.model(data) + return await created.save() + } + + /** + * 批量创建文档 + */ + async createMany(data: CreateDocumentType[]) { + return await this.model.insertMany(data) + } + + /** + * 根据ID更新文档 + */ + async updateById( + id: string, + update: UpdateDocumentType, + options?: QueryOptions, + ) { + return await this.model.findByIdAndUpdate(id, update, { new: true, ...options }).exec() + } + + /** + * 更新单个文档 + */ + protected async updateOne( + filter: FilterQuery, + update: UpdateDocumentType, + options?: QueryOptions, + ) { + return await this.model.findOneAndUpdate(filter, update, { new: true, ...options }).exec() + } + + /** + * 根据ID删除文档 + */ + async deleteById(id: string, options?: QueryOptions) { + return await this.model.findByIdAndDelete(id, options).exec() + } + + /** + * 删除单个文档 + */ + protected async deleteOne(filter: FilterQuery, options?: (DeleteOptions & MongooseBaseQueryOptions)) { + return await this.model.deleteOne(filter, options).exec() + } + + /** + * 批量删除文档 + */ + protected async deleteMany(filter: FilterQuery): Promise { + await this.model.deleteMany(filter).exec() + } + + /** + * 分页查询 + */ + protected async findWithPagination(params: PaginationParams) { + const { page, pageSize, filter = {}, options = {} } = params + const skip = (page - 1) * pageSize + + const findOptions = { ...options, skip, limit: pageSize } + + const [items, total] = await Promise.all([ + this.model.find(filter, undefined, findOptions).exec(), + this.model.countDocuments(filter).exec(), + ]) + + return [items, total] as const + } + + /** + * 查找单个文档 + */ + protected async findOne(filter: FilterQuery, options?: QueryOptions) { + return await this.model.findOne(filter, undefined, options).exec() + } + + /** + * 查找多个文档 + */ + protected async find(filter: FilterQuery = {}, options?: QueryOptions) { + return await this.model.find(filter, undefined, options).exec() + } + + /** + * 统计文档数量 + */ + protected async count(filter: FilterQuery = {}): Promise { + return await this.model.countDocuments(filter).exec() + } + + /** + * 检查文档是否存在 + */ + protected async exists(filter: FilterQuery): Promise { + const result = await this.model.exists(filter).exec() + return result !== null + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/blog.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/blog.repository.ts new file mode 100644 index 000000000..bd1107f26 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/blog.repository.ts @@ -0,0 +1,40 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { Blog } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListBlogParams extends Pagination { + keyword?: string + createdAt?: Date[] +} + +export class BlogRepository extends BaseRepository { + constructor( + @InjectModel(Blog.name) blogModel: Model, + ) { + super(blogModel) + } + + async listWithPagination(params: ListBlogParams) { + const { page, pageSize, keyword, createdAt } = params + + const filter: FilterQuery = {} + if (createdAt) { + filter.createdAt = { + $gte: createdAt[0], + $lte: createdAt[1], + } + } + if (keyword) { + filter.content = { $regex: keyword, $options: 'i' } + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/browser-profile.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/browser-profile.repository.ts new file mode 100644 index 000000000..0c1e53ccc --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/browser-profile.repository.ts @@ -0,0 +1,49 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { BrowserProfile } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListBrowserProfileParams extends Pagination { + accountId?: string + profileId?: string + cloudSpaceId?: string +} + +export class BrowserProfileRepository extends BaseRepository { + constructor( + @InjectModel(BrowserProfile.name) browserProfileModel: Model, + ) { + super(browserProfileModel) + } + + async listWithPagination(params: ListBrowserProfileParams) { + const { page, pageSize, accountId, profileId, cloudSpaceId } = params + + const filter: FilterQuery = {} + if (accountId) + filter.accountId = accountId + if (profileId) + filter.profileId = profileId + if (cloudSpaceId) + filter.cloudSpaceId = cloudSpaceId + + return await this.findWithPagination({ + page, + pageSize, + filter, + }) + } + + async listByCloudSpaceId(cloudSpaceId: string): Promise { + return await this.find({ cloudSpaceId }) + } + + async getByCloudSpaceId(cloudSpaceId: string): Promise { + return await this.findOne({ cloudSpaceId }) + } + + async deleteByCloudSpaceId(cloudSpaceId: string): Promise { + await this.deleteMany({ cloudSpaceId }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/checkout.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/checkout.repository.ts new file mode 100644 index 000000000..e49f4769b --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/checkout.repository.ts @@ -0,0 +1,118 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { ICheckoutStatus } from '@yikart/stripe' +import { FilterQuery, Model, QueryOptions } from 'mongoose' +import { Checkout } from '../schemas' +import { BaseRepository, UpdateDocumentType } from './base.repository' + +export interface ListCheckoutParams extends Pagination { + userId?: string + customer?: string + status?: ICheckoutStatus | ICheckoutStatus[] + mode?: string + createdAt?: Date[] + search?: string +} + +export class CheckoutRepository extends BaseRepository { + constructor( + @InjectModel(Checkout.name) checkoutModel: Model, + ) { + super(checkoutModel) + } + + /** + * 根据ID获取单个文档 + */ + override async getById(id: string, options?: QueryOptions) { + return await this.model.findOne({ id }, undefined, options).exec() + } + + async listWithPagination(params: ListCheckoutParams) { + const { page, pageSize, userId, customer, status, mode, createdAt, search } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (customer) + filter.customer = customer + if (status) { + if (Array.isArray(status)) { + filter.status = { $in: status } + } + else { + filter.status = status + } + } + if (mode) + filter.mode = mode + if (createdAt) { + filter.created = { + $gte: Math.floor(createdAt[0].getTime() / 1000), + $lte: Math.floor(createdAt[1].getTime() / 1000), + } + } + if (search) { + const searchExample = { + $regex: search, + $options: 'i', + } + filter.$or = [ + { id: searchExample }, + { charge: searchExample }, + { userId: searchExample }, + ] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { created: -1 } }, + }) + } + + override async updateById( + id: string, + update: UpdateDocumentType, + options?: QueryOptions, + ) { + return super.updateOne({}, update, options) + } + + async getByUserId(userId: string) { + return await this.find({ userId }, { sort: { created: -1 } }) + } + + async deleteByUserId(userId: string): Promise { + await this.deleteMany({ userId }) + } + + async upsertById(id: string, data: Partial) { + return await this.model.findOneAndUpdate({ id }, { $set: data }, { upsert: true, new: true }).exec() + } + + async getByIdAndUserId(id: string, userId?: string) { + const filter: FilterQuery = { id } + if (userId) { + filter.userId = userId + } + return await this.findOne(filter) + } + + async getByChargeAndUserId(charge: string, userId?: string) { + const filter: FilterQuery = { charge } + if (userId) { + filter.userId = userId + } + return await this.findOne(filter) + } + + async getByChargeAndStatus(charge: string, status: ICheckoutStatus) { + return await this.findOne({ charge, status }) + } + + async getByIdAndStatus(id: string, status: ICheckoutStatus) { + return await this.findOne({ id, status }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/cloud-space.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/cloud-space.repository.ts new file mode 100644 index 000000000..7ccbcdadd --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/cloud-space.repository.ts @@ -0,0 +1,94 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination, RangeFilter } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { CloudSpaceRegion, CloudSpaceStatus } from '../enums' +import { CloudSpace } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListCloudSpaceParams extends Pagination { + userId?: string + region?: CloudSpaceRegion + status?: CloudSpaceStatus +} + +export interface ListCloudSpaceByUserIdParams { + userId: string + region?: CloudSpaceRegion + status?: CloudSpaceStatus +} + +export interface ListCloudSpacesByStatusParams { + status: CloudSpaceStatus + expiredAt?: RangeFilter +} + +export class CloudSpaceRepository extends BaseRepository { + constructor( + @InjectModel(CloudSpace.name) cloudSpaceModel: Model, + ) { + super(cloudSpaceModel) + } + + async listWithPagination(params: ListCloudSpaceParams) { + const { page, pageSize, userId, region, status } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (region) + filter.region = region + if (status) + filter.status = status + + return await this.findWithPagination({ + page, + pageSize, + filter, + }) + } + + async listByStatus(params: ListCloudSpacesByStatusParams): Promise { + const { status, expiredAt } = params + + const filter: FilterQuery = { + status, + } + + if (expiredAt) { + const [start, end] = expiredAt + if (start && end) { + filter.expiredAt = { + $gt: start, + $lte: end, + } + } + else if (end) { + filter.expiredAt = { $lte: end } + } + else if (start) { + filter.expiredAt = { $gte: start } + } + } + + return await this.model + .find(filter) + .sort({ expiredAt: 1 }) + .exec() + } + + async listByUserId(params: ListCloudSpaceByUserIdParams) { + const { userId, region, status } = params + const filter: FilterQuery = { + userId, + } + if (region) + filter.region = region + if (status) + filter.status = status + + return await this.model + .find(filter) + .sort() + .exec() + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/coupon.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/coupon.repository.ts new file mode 100644 index 000000000..7930923b1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/coupon.repository.ts @@ -0,0 +1,47 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { ICurrency, IDuration } from '@yikart/stripe' +import { FilterQuery, Model } from 'mongoose' +import { Coupon } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListCouponParams extends Pagination { + duration?: IDuration + currency?: ICurrency + createdAt?: Date[] +} + +export class CouponRepository extends BaseRepository { + constructor( + @InjectModel(Coupon.name) couponModel: Model, + ) { + super(couponModel) + } + + async listWithPagination(params: ListCouponParams) { + const { page, pageSize, duration, currency, createdAt } = params + + const filter: FilterQuery = {} + if (duration) + filter.duration = duration + if (currency) + filter.currency = currency + if (createdAt) { + filter.created = { + $gte: Math.floor(createdAt[0].getTime() / 1000), + $lte: Math.floor(createdAt[1].getTime() / 1000), + } + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { created: -1 } }, + }) + } + + async upsertById(id: string, data: Partial) { + return await this.model.findOneAndUpdate({ id }, data, { upsert: true, new: true }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/feedback.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/feedback.repository.ts new file mode 100644 index 000000000..5ae70dc05 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/feedback.repository.ts @@ -0,0 +1,60 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { FeedbackType } from '../enums' +import { Feedback } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListFeedbackParams extends Pagination { + userId?: string + type?: FeedbackType + userName?: string + createdAt?: string[] + keyword?: string +} + +export class FeedbackRepository extends BaseRepository { + constructor( + @InjectModel(Feedback.name) feedbackModel: Model, + ) { + super(feedbackModel) + } + + async listWithPagination(params: ListFeedbackParams) { + const { page, pageSize, userId, type, userName, createdAt, keyword } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (type) + filter.type = type + if (userName) + filter.userName = userName + if (createdAt) { + filter.createdAt = { + $gte: createdAt[0], + $lte: createdAt[1], + } + } + if (keyword) { + filter.$or = [ + { content: { $regex: keyword, $options: 'i' } }, + { userName: { $regex: keyword, $options: 'i' } }, + ] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + }) + } + + async listByUserId(userId: string) { + return await this.find({ userId }) + } + + async listByType(type: FeedbackType) { + return await this.find({ type }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/income-record.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/income-record.repository.ts new file mode 100644 index 000000000..3f2fdd01a --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/income-record.repository.ts @@ -0,0 +1,207 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination, RangeFilter } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { IncomeStatus, IncomeType } from '../enums' +import { IncomeRecord, User } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListIncomeRecordParams extends Pagination { + userId?: string + type?: IncomeType + status?: IncomeStatus + withdrawId?: string + relId?: string + createdAt?: RangeFilter +} + +export interface ListIncomeRecordByUserIdParams { + userId: string + type?: IncomeType + status?: IncomeType + withdrawId?: string + relId?: string +} + +export interface ListIncomeRecordByStatusParams { + status: IncomeStatus + type?: IncomeType + createdAt?: RangeFilter +} + +export class IncomeRecordRepository extends BaseRepository { + constructor( + @InjectModel(IncomeRecord.name) private readonly incomeRecordModel: Model, + @InjectModel(User.name) private readonly userModel: Model, + ) { + super(incomeRecordModel) + } + + /** + * 增加收入 + * @param data + */ + async add(data: { + userId: string + amount: number + type: IncomeType + description?: string + metadata?: Record + relId?: string + withdrawId?: string + }): Promise { + await this.userModel.db.transaction(async () => { + await this.userModel.updateOne( + { _id: data.userId }, + { $inc: { income: data.amount, totalIncome: data.amount } }, + ) + return await this.incomeRecordModel.create(data) + }) + } + + /** + * 扣减 + * @param data 扣减 + */ + async deduct(data: { + userId: string + amount: number + type: IncomeType + description?: string + metadata?: Record + relId?: string + withdrawId?: string + }) { + const { userId, amount } = data + + await this.userModel.db.transaction(async () => { + const result = await this.userModel.updateOne( + { _id: userId, income: { $gte: amount } }, // 查询条件包含余额检查 + { + $inc: { income: -amount }, + }, + ) + if (!result.matchedCount) + throw new Error('not enough balance') + + // 删除redis缓存 + // this.redisService.del(`UserInfo:${userId}`) + + data.amount = -amount + + await this.incomeRecordModel.create({ status: IncomeStatus.DO, ...data }) + }) + return true + } + + /** + * 获取用户积分余额 + * @param userId 用户ID + * @returns 用户积分余额 + */ + async getBalance(userId: string): Promise { + const user = await this.userModel.findById(userId).exec() + return user?.income || 0 + } + + // 提现 + async withdraw(id: string, withdrawId?: string): Promise { + const res = await this.incomeRecordModel.updateOne({ _id: id }, { status: IncomeStatus.DO, withdrawId }).exec() + return res.modifiedCount > 0 + } + + // 获取用户全部未提现的收入 + async getAllWithdrawableIncome(userId: string): Promise { + return await this.incomeRecordModel.find({ status: IncomeStatus.WAIT, userId }).exec() + } + + /** + * 获取记录信息 + * @param id 用户ID + * @returns 用户积分余额 + */ + async getRecordInfo(id: string): Promise { + const res = await this.incomeRecordModel.findById(id).exec() + return res + } + + async listWithPagination(params: ListIncomeRecordParams) { + const { page, pageSize, userId, type, status, withdrawId, relId, createdAt } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (type) + filter.type = type + if (status) + filter.status = status + if (withdrawId) + filter.withdrawId = withdrawId + if (relId) + filter.relId = relId + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async listByUserId(params: ListIncomeRecordByUserIdParams): Promise { + const { userId, type, status, withdrawId, relId } = params + const filter: FilterQuery = { + userId, + } + if (type) + filter.type = type + if (status) + filter.status = status + if (withdrawId) + filter.withdrawId = withdrawId + if (relId) + filter.relId = relId + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + async listByStatus(params: ListIncomeRecordByStatusParams): Promise { + const { status, type, createdAt } = params + const filter: FilterQuery = { + status, + } + if (type) + filter.type = type + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + async getByWithdrawId(withdrawId: string): Promise { + return await this.findOne({ withdrawId }) + } + + async getByRelId(relId: string): Promise { + return await this.findOne({ relId }) + } + + async updateStatusById(id: string, status: IncomeStatus): Promise { + return await this.updateById(id, { status }) + } + + async deleteByUserId(userId: string): Promise { + await this.deleteMany({ userId }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/index.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/index.ts new file mode 100644 index 000000000..cff08016f --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/index.ts @@ -0,0 +1,101 @@ +import { AccountRepository } from './account.repository' +import { AccountGroupRepository } from './accountGroup.repository' +import { AdminAccountRepository } from './admin/admin-account.repository' +import { AdminUserRepository } from './admin/admin-user.repository' +import { ManagerRepository } from './admin/manager.repository' +import { AiLogRepository } from './ai-log.repository' +import { AppConfigRepository } from './app-config.repository' +import { AppReleaseRepository } from './app-release.repository' +import { BlogRepository } from './blog.repository' +import { BrowserProfileRepository } from './browser-profile.repository' +import { CheckoutRepository } from './checkout.repository' +import { CloudSpaceRepository } from './cloud-space.repository' +import { CouponRepository } from './coupon.repository' +import { FeedbackRepository } from './feedback.repository' +import { IncomeRecordRepository } from './income-record.repository' +import { MaterialRepository } from './material.repository' +import { MaterialGroupRepository } from './materialGroup.repository' +import { MaterialTaskRepository } from './materialTask.repository' +import { MediaRepository } from './media.repository' +import { MediaGroupRepository } from './mediaGroup.repository' +import { MultiloginAccountRepository } from './multilogin-account.repository' +import { NotificationRepository } from './notification.repository' +import { PointsRecordRepository } from './points-record.repository' +import { PriceRepository } from './price.repository' +import { ProductRepository } from './product.repository' +import { PublishRecordRepository } from './publishRecord.repository' +import { SubscriptionRepository } from './subscription.repository' +import { UserWalletAccountRepository } from './user-wallet-account.repository' +import { UserWalletRepository } from './user-wallet.repository' +import { UserRepository } from './user.repository' +import { VipRepository } from './vip.repository' +import { WithdrawRecordRepository } from './withdraw-record.repository' + +export * from './account.repository' +export * from './accountGroup.repository' +export * from './admin/admin-account.repository' +export * from './admin/admin-user.repository' +export * from './admin/manager.repository' +export * from './ai-log.repository' +export * from './app-config.repository' +export * from './app-release.repository' +export * from './base.repository' +export * from './blog.repository' +export * from './browser-profile.repository' +export * from './checkout.repository' +export * from './cloud-space.repository' +export * from './coupon.repository' +export * from './feedback.repository' +export * from './income-record.repository' +export * from './material.repository' +export * from './materialGroup.repository' +export * from './materialTask.repository' +export * from './media.repository' +export * from './mediaGroup.repository' +export * from './multilogin-account.repository' +export * from './notification.repository' +export * from './points-record.repository' +export * from './price.repository' +export * from './product.repository' +export * from './publishRecord.repository' +export * from './subscription.repository' +export * from './user-wallet-account.repository' +export * from './user-wallet.repository' +export * from './user.repository' +export * from './vip.repository' +export * from './withdraw-record.repository' + +export const repositories = [ + AiLogRepository, + AppConfigRepository, + BlogRepository, + CloudSpaceRepository, + BrowserProfileRepository, + CheckoutRepository, + CouponRepository, + FeedbackRepository, + IncomeRecordRepository, + MultiloginAccountRepository, + NotificationRepository, + PointsRecordRepository, + PriceRepository, + ProductRepository, + SubscriptionRepository, + UserWalletAccountRepository, + UserWalletRepository, + UserRepository, + WithdrawRecordRepository, + AppReleaseRepository, + AccountRepository, + AccountGroupRepository, + MediaRepository, + MediaGroupRepository, + VipRepository, + AdminAccountRepository, + ManagerRepository, + AdminUserRepository, + MaterialGroupRepository, + MaterialRepository, + MaterialTaskRepository, + PublishRecordRepository, +] as const diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/material.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/material.repository.ts new file mode 100644 index 000000000..0ec7c3c4e --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/material.repository.ts @@ -0,0 +1,206 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: Material material + */ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { FilterQuery, Model, RootFilterQuery } from 'mongoose' +import { Material, MaterialStatus } from '../schemas' +import { BaseRepository } from './base.repository' + +@Injectable() +export class MaterialRepository extends BaseRepository { + constructor( + @InjectModel(Material.name) + private readonly materialModel: Model, + ) { + super(materialModel) + } + + override async create(newData: Partial) { + return await this.materialModel.create(newData) + } + + async delOne(id: string): Promise { + const res = await this.materialModel.deleteOne({ _id: id }) + return res.deletedCount > 0 + } + + // 批量删除 + async delByIds(ids: string[], filter?: FilterQuery): Promise { + const res = await this.materialModel.deleteMany({ _id: { $in: ids }, ...filter }) + return res.deletedCount > 0 + } + + // 批量删除 + async delByFilter(filter: FilterQuery): Promise { + const res = await this.materialModel.deleteMany(filter) + return res.deletedCount > 0 + } + + // 删除 + async delByMinUseCount(groupId: string, minUseCount: number): Promise { + const res = await this.materialModel.deleteMany({ groupId, useCount: { $gte: minUseCount } }) + return res.deletedCount > 0 + } + + /** + * 更新状态 + * @param id + * @param status + * @param message + * @returns + */ + async updateStatus( + id: string, + status: MaterialStatus, + message: string, + ): Promise { + const res = await this.materialModel.updateOne( + { _id: id }, + { $set: { status, message } }, + ) + return res.modifiedCount > 0 + } + + async updateInfo(id: string, newData: Partial): Promise { + const res = await this.materialModel.updateOne( + { _id: id }, + { $set: newData }, + ) + return res.modifiedCount > 0 + } + + async getInfo(id: string) { + return await this.materialModel.findOne({ _id: id }) + } + + async optimalInGroup(groupId: string) { + const data = await this.materialModel + .findOne({ + groupId, + status: MaterialStatus.SUCCESS, + }) + .sort({ useCount: 1, createdAt: -1 }) + .lean() + + return data + } + + // 获取列表 + async getList( + inFilter: { + userId?: string + userType?: UserType + title?: string + groupId?: string + status?: MaterialStatus + ids?: string[] + useCount?: number + }, + pageInfo: { + pageNo: number + pageSize: number + }, + ) { + const { pageNo, pageSize } = pageInfo + + const filter: RootFilterQuery = { + ...(inFilter.userId && { userId: inFilter.userId }), + userType: inFilter.userType || UserType.User, + ...(inFilter.title && { + title: { $regex: inFilter.title, $options: 'i' }, + }), + ...(inFilter.groupId && { groupId: inFilter.groupId }), + ...(inFilter.status !== undefined && { status: inFilter.status }), + ...(inFilter.ids && { _id: { $in: [inFilter.ids] } }), + ...(inFilter.useCount !== undefined && { useCount: { $gte: inFilter.useCount } }), + } + + const [total, list] = await Promise.all([ + this.materialModel.countDocuments(filter), + this.materialModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean(), + ]) + + return { + total, + list, + } + } + + // 获取列表 + async listByIds(materialIds: string[], inFilter?: RootFilterQuery) { + const filter: RootFilterQuery = { + _id: { + $in: materialIds, + }, + status: MaterialStatus.SUCCESS, + ...inFilter, + } + const list = await this.materialModel + .find(filter) + .sort({ useCount: 1, createdAt: -1 }) + .lean() + + return list + } + + async tableListByIds(materialIds: string[], page: { + pageNo: number + pageSize: number + }, inFilter?: RootFilterQuery) { + const filter: RootFilterQuery = { + _id: { + $in: materialIds, + }, + ...inFilter, + } + + const [total, list] = await Promise.all([ + this.materialModel.countDocuments(filter), + this.materialModel + .find(filter) + .sort({ useCount: 1, createdAt: -1 }) + .skip((page.pageNo! - 1) * page.pageSize) + .limit(page.pageSize) + .lean(), + ]) + + return { + total, + list, + } + } + + async optimalByIds(materialIds: string[]): Promise { + const data = await this.materialModel + .findOne({ + _id: { + $in: materialIds, + }, + status: MaterialStatus.SUCCESS, + }) + .sort({ useCount: 1, createdAt: -1 }) + .lean() + + return data + } + + // 增加草稿的使用次数 + async addUseCount(id: string) { + const res = await this.materialModel.updateOne( + { _id: id }, + { $inc: { useCount: 1 } }, + ) + return res.modifiedCount > 0 + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialGroup.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialGroup.repository.ts new file mode 100644 index 000000000..3696c19b8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialGroup.repository.ts @@ -0,0 +1,125 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: MaterialGroup materialGroup + */ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { MaterialGroup, MaterialType } from '../schemas' +import { BaseRepository } from './base.repository' + +@Injectable() +export class MaterialGroupRepository extends BaseRepository { + logger = new Logger(MaterialGroupRepository.name) + constructor( + @InjectModel(MaterialGroup.name) + private readonly materialGroupModel: Model, + ) { + super(materialGroupModel) + } + + override async create(newData: Partial) { + return await this.materialGroupModel.create(newData) + } + + private async createDefaultGroupIfNotExists(userId: string, type: MaterialType): Promise { + try { + // 检查是否已存在默认组 + const existingGroup = await this.materialGroupModel.findOne({ + userId, + type, + userType: UserType.User, + isDefault: true, + }) + + // 如果已存在默认组,直接返回 true + if (existingGroup) { + return true + } + + // 创建默认组 + const newGroup = await this.materialGroupModel.create({ + userId, + name: 'Default', + userType: UserType.User, + type, + isDefault: true, + }) + + return !!newGroup + } + catch (error) { + this.logger.error(`Error creating default ${type} group:`, error) + return false + } + } + + async createDefault(userId: string): Promise { + try { + // 为文章和视频类型创建默认组 + const defaultGroups = await Promise.all([ + this.createDefaultGroupIfNotExists(userId, MaterialType.ARTICLE), + this.createDefaultGroupIfNotExists(userId, MaterialType.VIDEO), + ]) + // 如果任何一个组创建失败,则返回 false + return defaultGroups.every(result => result === true) + } + catch (error) { + this.logger.error('Error creating default material groups:', error) + return false + } + } + + // 删除 + async delete(id: string): Promise { + const res = await this.materialGroupModel.deleteOne({ _id: id }) + return res.deletedCount > 0 + } + + // 修改 + async update(id: string, newData: Partial) { + const res = await this.materialGroupModel.updateOne({ _id: id }, newData) + return res.modifiedCount > 0 + } + + async getInfo(id: string) { + const info = await this.materialGroupModel.findOne({ _id: id }) + return info + } + + // 获取列表 + async getList(inFilter: { + userId?: string + title?: string + userType?: UserType + }, pageInfo: { + pageNo: number + pageSize: number + }) { + const { pageNo, pageSize } = pageInfo + const filter: RootFilterQuery = { + ...(inFilter.userId && { userId: inFilter.userId }), + ...(inFilter.userType && { userType: inFilter.userType }), + ...(inFilter.title && { title: { $regex: inFilter.title, $options: 'i' } }), + } + + const [total, list] = await Promise.all([ + this.materialGroupModel.countDocuments(filter), + this.materialGroupModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean(), + ]) + + return { + total, + list, + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialTask.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialTask.repository.ts new file mode 100644 index 000000000..6f30ed08e --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/materialTask.repository.ts @@ -0,0 +1,36 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: Material material + */ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { MaterialTask } from '../schemas' +import { BaseRepository } from './base.repository' + +@Injectable() +export class MaterialTaskRepository extends BaseRepository { + logger = new Logger(MaterialTaskRepository.name) + constructor( + @InjectModel(MaterialTask.name) + private readonly materialTaskModel: Model, + ) { + super(materialTaskModel) + } + + override async create(newData: Partial) { + return await this.materialTaskModel.create(newData) + } + + async update(id: string, newData: Partial): Promise { + const res = await this.materialTaskModel.updateOne({ _id: id }, newData) + return res.modifiedCount > 0 + } + + async getInfo(id: string) { + return await this.materialTaskModel.findOne({ _id: id }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/media.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/media.repository.ts new file mode 100644 index 000000000..a4bcf7e24 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/media.repository.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { FilterQuery, Model, RootFilterQuery, Types } from 'mongoose' +import { Media, MediaType } from '../schemas/media.schema' +import { BaseRepository } from './base.repository' + +@Injectable() +export class MediaRepository extends BaseRepository { + constructor( + @InjectModel(Media.name) + private readonly mediaModel: Model, + ) { + super(mediaModel) + } + + override async create(newData: Omit) { + return await this.mediaModel.create(newData) + } + + // 删除素材 + async delOne(id: string): Promise { + const res = await this.mediaModel.deleteOne({ _id: id }) + return res.deletedCount > 0 + } + + async getListByIds(ids: string[]) { + const mediaList = await this.mediaModel.find({ _id: { $in: ids } }) + return mediaList + } + + // 批量删除素材 + async delByIds(ids: string[], filter?: FilterQuery): Promise { + const res = await this.mediaModel.deleteMany({ _id: { $in: ids }, ...filter }) + return res.deletedCount > 0 + } + + // 批量删除素材 + async getListByFilter(filter: FilterQuery) { + const mediaList = await this.mediaModel.find(filter) + return mediaList + } + + // 批量删除素材 + async delByFilter(filter: FilterQuery): Promise { + const res = await this.mediaModel.deleteMany(filter) + return res.deletedCount > 0 + } + + // 更新 + async updateInfo(id: string, newData: Partial): Promise { + const res = await this.mediaModel.updateOne({ _id: id }, { $set: newData }) + return res.modifiedCount > 0 + } + + async getInfo(id: string) { + return await this.mediaModel.findOne({ _id: id }) + } + + /** + * 获取指定分组下的媒体 + * @param groupId + * @returns + */ + async getListByGroup(groupId: string) { + return await this.mediaModel.find({ groupId }) + } + + // 获取列表 + async getList(inFilter: { + userId: string + groupId?: string + type?: MediaType + userType?: UserType + useCount?: number + }, pageInfo: { + pageNo: number + pageSize: number + }) { + const { pageNo, pageSize } = pageInfo + + const filter: RootFilterQuery = { + userId: inFilter.userId, + ...(inFilter.groupId && { groupId: inFilter.groupId }), + userType: inFilter.userType || UserType.User, + ...(inFilter.type && { type: inFilter.type }), + ...(inFilter.useCount !== undefined && { useCount: { $gte: inFilter.useCount } }), + } + + const total = await this.mediaModel.countDocuments(filter) + const list = await this.mediaModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean() + .exec() + + return { + total, + list, + } + } + + /** + * 检查指定组是否为空(不包含任何媒体文件) + * @param groupId 组ID + * @returns 如果组为空返回true,否则返回false + */ + async checkIsEmptyGroup(groupId: string): Promise { + const exists = await this.mediaModel.exists({ groupId }) + return !exists + } + + // 使用次数增加 + async addUseCount(id: string): Promise { + const res = await this.mediaModel.updateOne({ _id: id }, { $inc: { useCount: 1 } }) + return res.modifiedCount > 0 + } + + async addUseCountOfList(ids: string[], filter?: FilterQuery): Promise { + const idList = ids.map(id => new Types.ObjectId(id)) + const res = await this.mediaModel.updateMany({ _id: { $in: idList }, ...filter }, { $inc: { useCount: 1 } }) + return res.modifiedCount > 0 + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/mediaGroup.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/mediaGroup.repository.ts new file mode 100644 index 000000000..21a46dcd0 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/mediaGroup.repository.ts @@ -0,0 +1,127 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: Material material + */ +import { Injectable, Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { MediaType } from '../schemas/media.schema' +import { MediaGroup } from '../schemas/mediaGroup.schema' +import { BaseRepository } from './base.repository' + +@Injectable() +export class MediaGroupRepository extends BaseRepository { + logger = new Logger(MediaGroupRepository.name) + constructor( + @InjectModel(MediaGroup.name) + private readonly mediaGroupModel: Model, + ) { + super(mediaGroupModel) + } + + private async createDefaultGroupIfNotExists(userId: string, type: MediaType): Promise { + try { + // 检查是否已存在默认组 + const existingGroup = await this.mediaGroupModel.findOne({ + userId, + type, + isDefault: true, + }) + + // 如果已存在默认组,直接返回 true + if (existingGroup) { + return true + } + + // 创建默认组 + const newGroup = await this.mediaGroupModel.create({ + userId, + title: 'Default', + type, + isDefault: true, + }) + + return !!newGroup + } + catch (error) { + this.logger.error(`Error creating default ${type} group:`, error) + return false + } + } + + async createDefault(userId: string): Promise { + try { + const defaultGroups = await Promise.all([ + this.createDefaultGroupIfNotExists(userId, MediaType.IMG), + this.createDefaultGroupIfNotExists(userId, MediaType.VIDEO), + ]) + // 如果任何一个组创建失败,则返回 false + return defaultGroups.every(result => result === true) + } + catch (error) { + this.logger.error('Error creating default media groups:', error) + return false + } + } + + override async create(newData: Partial) { + return await this.mediaGroupModel.create(newData) + } + + // 删除 + async delete(id: string): Promise { + const res = await this.mediaGroupModel.deleteOne({ _id: id }) + return res.deletedCount > 0 + } + + // 修改 + async update(id: string, newData: Partial) { + const res = await this.mediaGroupModel.updateOne({ _id: id }, newData) + return res.modifiedCount > 0 + } + + async getInfo(id: string) { + return await this.mediaGroupModel.findOne({ _id: id }) + } + + // 获取列表 + async getList(inFilter: { + userId?: string + userType?: UserType + title?: string + type?: MediaType + }, pageInfo: { + pageNo: number + pageSize: number + }) { + const { pageNo, pageSize } = pageInfo + const filter: RootFilterQuery = { + ...(inFilter.userId && { userId: inFilter.userId }), + userType: inFilter.userType || UserType.User, + ...(inFilter.type && { type: inFilter.type }), + ...(inFilter.title && { + title: { $regex: inFilter.title, $options: 'i' }, + }), + } + + const [total, list] = await Promise.all([ + this.mediaGroupModel.countDocuments(filter), + this.mediaGroupModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean() + .exec(), + ]) + + return { + total, + list, + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/multilogin-account.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/multilogin-account.repository.ts new file mode 100644 index 000000000..e94e91798 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/multilogin-account.repository.ts @@ -0,0 +1,42 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { MultiloginAccount } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListMultiloginAccountParams extends Pagination { + email?: string + minMaxProfiles?: number + maxMaxProfiles?: number + hasAvailableSlots?: boolean +} + +export class MultiloginAccountRepository extends BaseRepository { + constructor( + @InjectModel(MultiloginAccount.name) multiloginAccountModel: Model, + ) { + super(multiloginAccountModel) + } + + async listWithPagination(params: ListMultiloginAccountParams) { + const { page, pageSize, email, minMaxProfiles, maxMaxProfiles, hasAvailableSlots } = params + + const filter: FilterQuery = {} + if (email) + filter.email = email + if (minMaxProfiles !== undefined) + filter.maxProfiles = { $gte: minMaxProfiles } + if (maxMaxProfiles !== undefined) + filter.maxProfiles = { ...filter.maxProfiles, $lte: maxMaxProfiles } + if (hasAvailableSlots === true) + filter.$expr = { $lt: ['$currentProfiles', '$maxProfiles'] } + else if (hasAvailableSlots === false) + filter.$expr = { $gte: ['$currentProfiles', '$maxProfiles'] } + + return await this.findWithPagination({ + page, + pageSize, + filter, + }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/notification.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/notification.repository.ts new file mode 100644 index 000000000..e6f7e71dd --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/notification.repository.ts @@ -0,0 +1,164 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model, Types } from 'mongoose' +import { NotificationStatus, NotificationType } from '../enums' +import { Notification } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListNotificationParams extends Pagination { + userId?: string + type?: NotificationType + status?: NotificationStatus + relatedId?: string + createdAt?: string[] + keyword?: string +} + +export class NotificationRepository extends BaseRepository { + constructor( + @InjectModel(Notification.name) notificationModel: Model, + ) { + super(notificationModel) + } + + async listWithPagination(params: ListNotificationParams) { + const { page, pageSize, userId, type, status, relatedId, createdAt, keyword } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = new Types.ObjectId(userId) + if (type) + filter.type = type + if (status) + filter.status = status + if (relatedId) + filter.relatedId = relatedId + if (createdAt) { + filter.createdAt = { + $gte: createdAt[0], + $lte: createdAt[1], + } + } + if (keyword) { + filter.$or = [ + { title: { $regex: keyword, $options: 'i' } }, + { content: { $regex: keyword, $options: 'i' } }, + ] + } + + // 排除已删除的通知 + filter.deletedAt = { $exists: false } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async getByIdAndUserId(id: string, userId: string) { + return await this.model.findOne({ + _id: id, + userId, + deletedAt: { $exists: false }, + }) + } + + async updateByIdsAsRead(notificationIds: string[], userId: string) { + const notifications = await this.model.find({ + _id: { $in: notificationIds }, + userId, + deletedAt: { $exists: false }, + status: NotificationStatus.Unread, + }) + + if (notifications.length === 0) { + return { affectedCount: 0 } + } + + const ids = notifications.map(n => n.id) + const result = await this.model.updateMany( + { + _id: { $in: ids }, + }, + { + $set: { + status: NotificationStatus.Read, + readAt: new Date(), + }, + }, + ) + return { affectedCount: result.modifiedCount } + } + + async updateByUserIdAllAsRead(userId: string) { + const result = await this.model.updateMany( + { + userId, + deletedAt: { $exists: false }, + status: NotificationStatus.Unread, + }, + { + $set: { + status: NotificationStatus.Read, + readAt: new Date(), + }, + }, + ) + + return { affectedCount: result.modifiedCount } + } + + async deleteByIds(notificationIds: string[], userId?: string) { + const filter: FilterQuery = { + _id: { $in: notificationIds }, + deletedAt: { $exists: false }, + } + + if (userId) { + filter.userId = userId + } + + const result = await this.model.updateMany( + filter, + { + $set: { + deletedAt: new Date(), + }, + }, + ) + + return { affectedCount: result.modifiedCount } + } + + async countByUserIdUnread(userId: string, filter?: { + type?: NotificationType + }) { + const count = await this.model.countDocuments({ + userId, + status: NotificationStatus.Unread, + deletedAt: { $exists: false }, + ...filter, + }) + + return { count } + } + + async listByUserId(userId: string, status?: NotificationStatus) { + const filter: FilterQuery = { + userId: new Types.ObjectId(userId), + deletedAt: { $exists: false }, + } + if (status) + filter.status = status + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + override async deleteById(id: string) { + return await this.updateById(id, { + deletedAt: new Date(), + }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/points-record.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/points-record.repository.ts new file mode 100644 index 000000000..d941fa7b4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/points-record.repository.ts @@ -0,0 +1,409 @@ +import { Logger } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { Pagination, RangeFilter } from '@yikart/common' +import dayjs from 'dayjs' +import * as _ from 'lodash' +import { FilterQuery, Model } from 'mongoose' +import { IPointStatus, PointsRecord, User } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListPointsRecordParams extends Pagination { + userId?: string + type?: string + createdAt?: RangeFilter +} + +export interface ListPointsRecordByUserIdParams { + userId: string + type?: string + createdAt?: RangeFilter +} + +export interface ListPointsRecordByTypeParams { + type: string + createdAt?: RangeFilter +} + +export class PointsRecordRepository extends BaseRepository { + logger = new Logger(PointsRecordRepository.name) + constructor( + @InjectModel(PointsRecord.name) private readonly pointsRecordModel: Model, + @InjectModel(User.name) private readonly userModel: Model, + ) { + super(pointsRecordModel) + } + + async listWithPagination(params: ListPointsRecordParams) { + const { page, pageSize, userId, type, createdAt } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (type) + filter.type = type + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async findVipOpintsAddRepordOfMonth(userId: string) { + const startTime = dayjs().subtract(30, 'day').toDate() + const time = [startTime, new Date()] + const list = await this.pointsRecordModel + .find({ + userId, + createdAt: { $gte: time[0], $lte: time[1] }, + type: 'vip_points', + }) + .sort({ createdAt: -1 }) + .exec() + + return list + } + + async addPoints( + user: User, + data: { + amount: number + type: string + description?: string + metadata?: Record + }, + ): Promise { + const { amount, type, description, metadata } = data + + await this.userModel.db.transaction(async () => { + await this.userModel.updateOne( + { _id: user.id }, + { $inc: { score: amount } }, + ) + + return await this.pointsRecordModel.create([ + { + userId: user.id, + amount, + balance: user.score + amount, + type, + description, + metadata, + }, + ]) + }) + } + + /** + * 扣减积分 + * @param data 扣减积分的数据 + */ + async deductPoints( + user: User, + data: { + amount: number + type: string + description?: string + metadata?: Record + }, + ): Promise { + const { amount, type, description, metadata } = data + + await this.userModel.db.transaction(async () => { + await this.userModel.updateOne( + { _id: user.id, score: { $gte: amount } }, // 查询条件包含余额检查 + { + $inc: { score: -amount }, // 原子性减少积分 + }, + ) + + await this.pointsRecordModel.create([ + { + userId: user.id, + amount: -amount, + balance: user.score - amount, + type, + description, + metadata, + createdAt: new Date(), + }, + ]) + }) + } + + async diKouCostByUser(user: User, updatedAt: any) { + const { id: userId } = user + try { + // 查询昨天该用户的所有花费的总和 + const $match = { + userId, + updatedAt, + status: IPointStatus.FREE, + amount: { $lt: 0 }, + } + const $group = { _id: null, pointCost: { $sum: '$amount' } } + const data = await this.pointsRecordModel.aggregate([ + { $match }, + { $group }, + ]) + + // 修复:正确获取pointCost值 + let pointCost = 0 + if (!_.isEmpty(data) && data[0] && _.has(data[0], 'pointCost')) { + pointCost = Math.abs(data[0].pointCost) // 取绝对值,因为花费是负数 + } + + if (pointCost <= 0) { + this.logger.debug(`No cost points found for user ${userId}`) + return + } + + this.logger.log(`Processing cost deduction for user ${userId}, cost: ${pointCost}`) + + const arr: any = [] + // 标记花费记录为已抵扣 + arr.push(this.pointsRecordModel.updateMany($match, { $set: { status: IPointStatus.TOTAL_DI_KOU } })) + + // 找到最近一年内所有新增的未被抵扣的积分记录,按照创建时间倒序依次抵扣直到抵扣结束 + const today = dayjs().startOf('day').valueOf() + const createdAt = { + $gt: dayjs(today).subtract(1, 'y').valueOf(), + $lt: today, + } + const condition = { + userId, + status: { $in: [IPointStatus.FREE, IPointStatus.PART_DI_KOU] }, + amount: { $gt: 0 }, + createdAt, + } + + const cursor = this.pointsRecordModel + .find(condition, 'id amount usedForDiKou status createdAt') + .sort({ createdAt: -1 }) + .cursor() + + let remainingCost = pointCost + for (let record: any = await cursor.next(); record !== null && remainingCost > 0; record = await cursor.next()) { + const { _id: recordId, amount, usedForDiKou } = record + const availableAmount = amount - (usedForDiKou || 0) + + if (availableAmount <= 0) + continue + + // 该条积分增加记录可以被完全抵扣 + if (remainingCost >= availableAmount) { + remainingCost -= availableAmount + arr.push( + this.pointsRecordModel.findByIdAndUpdate( + recordId, + { $set: { usedForDiKou: amount, status: IPointStatus.TOTAL_DI_KOU } }, + ), + ) + } + else { + // 该条积分增加记录不能被完全抵扣 + const newUsedForDiKou = (usedForDiKou || 0) + remainingCost + arr.push( + this.pointsRecordModel.findByIdAndUpdate( + recordId, + { $set: { usedForDiKou: newUsedForDiKou, status: IPointStatus.PART_DI_KOU } }, + ), + ) + remainingCost = 0 + } + } + + await Promise.all(arr) + this.logger.log(`Cost deduction completed for user ${userId}, processed cost: ${pointCost - remainingCost}`) + } + catch (error) { + this.logger.error(`Error processing cost deduction for user ${userId}`, error) + throw error + } + } + + async getPointBySub(user: User) { + const { id: userId, score } = user + try { + const arr: any = [] + // 获取今天即将过期的积分(一年前获取的积分) + const oneYearAgo = dayjs().startOf('day').subtract(1, 'y').valueOf() + const createdAt = { + $gte: dayjs(oneYearAgo).subtract(1, 'd').valueOf(), + $lt: oneYearAgo, + } + + // 查询完全未抵扣的积分记录 + const $match = { userId, createdAt, status: IPointStatus.FREE, amount: { $gt: 0 } } + const $group = { _id: null, pointGet: { $sum: '$amount' } } + const data = await this.pointsRecordModel.aggregate([ + { $match }, + { $group }, + ]) + + // 修复:正确获取pointGet值 + let pointGet = 0 + if (!_.isEmpty(data) && data[0] && _.has(data[0], 'pointGet')) { + pointGet = data[0].pointGet + } + + // 查询部分抵扣的积分记录 + const condition = { userId, createdAt, status: IPointStatus.PART_DI_KOU, amount: { $gt: 0 } } + const PartDiKou: any = await this.pointsRecordModel.findOne(condition, 'id amount usedForDiKou status createdAt') + + if (!_.isEmpty(PartDiKou)) { + const { _id: recordId, amount, usedForDiKou } = PartDiKou + const remainingAmount = amount - (usedForDiKou || 0) + if (remainingAmount > 0) { + pointGet += remainingAmount + // 标记为完全抵扣 + arr.push( + this.pointsRecordModel.findByIdAndUpdate( + recordId, + { $set: { status: IPointStatus.TOTAL_DI_KOU, usedForDiKou: amount } }, + ), + ) + } + } + + // 标记所有未抵扣的积分记录为已抵扣 + arr.push(this.pointsRecordModel.updateMany($match, { $set: { status: IPointStatus.TOTAL_DI_KOU } })) + + if (pointGet <= 0) { + this.logger.debug(`No expired points found for user ${userId}`) + return + } + + // 计算新的积分余额 + const newBalance = Math.max(0, score - pointGet) + + // 创建积分过期记录 + const pointsRecord = { + userId, + amount: -pointGet, + balance: newBalance, + type: 'system_point_expire', + description: `积分获取后有效期为一年,系统积分:${pointGet}已经过期`, + metadata: { + expiredAt: new Date(oneYearAgo), + originalScore: score, + expiredAmount: pointGet, + }, + createdAt: new Date(), + } + arr.push(this.pointsRecordModel.create(pointsRecord)) + + // 更新用户积分余额 + arr.push(this.userModel.findByIdAndUpdate(userId, { $set: { score: newBalance } })) + + await Promise.all(arr) + this.logger.log(`Point expiration processed for user ${userId}, expired: ${pointGet}, new balance: ${newBalance}`) + } + catch (error) { + this.logger.error(`Error processing point expiration for user ${userId}`, error) + throw error + } + } + + async getPoint10DayExp(user: User) { + const { id: userId } = user + try { + // 获取10天后即将过期的积分(一年前获取的积分) + const oneYearAgo = dayjs().startOf('day').subtract(1, 'y').valueOf() + const createdAt = { + $gte: dayjs(oneYearAgo).subtract(10, 'd').valueOf(), + $lt: oneYearAgo, + } + + // 查询完全未抵扣的积分记录 + const $match = { userId, createdAt, status: IPointStatus.FREE, amount: { $gt: 0 } } + const $group = { _id: null, pointGet: { $sum: '$amount' } } + const data = await this.pointsRecordModel.aggregate([ + { $match }, + { $group }, + ]) + + // 修复:正确获取pointGet值 + let pointGet = 0 + if (!_.isEmpty(data) && data[0] && _.has(data[0], 'pointGet')) { + pointGet = data[0].pointGet + } + + // 查询部分抵扣的积分记录 + const condition = { userId, createdAt, status: IPointStatus.PART_DI_KOU, amount: { $gt: 0 } } + const PartDiKou: any = await this.pointsRecordModel.findOne(condition, 'id amount usedForDiKou status createdAt') + + if (!_.isEmpty(PartDiKou)) { + const { amount, usedForDiKou } = PartDiKou + const remainingAmount = amount - (usedForDiKou || 0) + if (remainingAmount > 0) { + pointGet += remainingAmount + } + } + + // 更新用户的10天后过期积分字段 + await this.userModel.findByIdAndUpdate(userId, { $set: { tenDayExpPoint: pointGet } }) + + this.logger.debug(`Updated 10-day expiration points for user ${userId}: ${pointGet}`) + } + catch (error) { + this.logger.error(`Error updating 10-day expiration points for user ${userId}`, error) + throw error + } + } + + async listByUserId(params: ListPointsRecordByUserIdParams): Promise { + const { userId, type, createdAt } = params + const filter: FilterQuery = { + userId, + } + if (type) + filter.type = type + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + async listByType(params: ListPointsRecordByTypeParams): Promise { + const { type, createdAt } = params + const filter: FilterQuery = { + type, + } + if (createdAt) { + filter.createdAt = {} + if (createdAt[0]) + filter.createdAt.$gte = createdAt[0] + if (createdAt[1]) + filter.createdAt.$lte = createdAt[1] + } + + return await this.find(filter, { sort: { createdAt: -1 } }) + } + + async getLatestByUserId(userId: string): Promise { + return await this.findOne({ userId }, { sort: { createdAt: -1 } }) + } + + async getTotalPointsByUserId(userId: string): Promise { + const latest = await this.getLatestByUserId(userId) + return latest?.balance || 0 + } + + async deleteByUserId(userId: string): Promise { + await this.deleteMany({ userId }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/price.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/price.repository.ts new file mode 100644 index 000000000..c358f2e87 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/price.repository.ts @@ -0,0 +1,43 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { ICurrency } from '@yikart/stripe' +import { FilterQuery, Model } from 'mongoose' +import { Price } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListPriceParams extends Pagination { + product?: string + active?: boolean + currency?: ICurrency +} + +export class PriceRepository extends BaseRepository { + constructor( + @InjectModel(Price.name) priceModel: Model, + ) { + super(priceModel) + } + + async listWithPagination(params: ListPriceParams) { + const { page, pageSize, product, active, currency } = params + + const filter: FilterQuery = {} + if (product) + filter.product = product + if (active !== undefined) + filter.active = active + if (currency) + filter.currency = currency + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { unit_amount: 1 } }, + }) + } + + async upsertById(id: string, data: Partial) { + return await this.model.findOneAndUpdate({ id }, data, { upsert: true, new: true }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/product.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/product.repository.ts new file mode 100644 index 000000000..db0618795 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/product.repository.ts @@ -0,0 +1,39 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { Product } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListProductParams extends Pagination { + name?: string + active?: boolean +} + +export class ProductRepository extends BaseRepository { + constructor( + @InjectModel(Product.name) productModel: Model, + ) { + super(productModel) + } + + async listWithPagination(params: ListProductParams) { + const { page, pageSize, name, active } = params + + const filter: FilterQuery = {} + if (name) + filter.name = { $regex: name, $options: 'i' } + if (active !== undefined) + filter.active = active + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { name: 1 } }, + }) + } + + async upsertById(id: string, data: Partial) { + return await this.model.findOneAndUpdate({ id }, { $set: data }, { upsert: true, new: true }).exec() + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/publishRecord.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/publishRecord.repository.ts new file mode 100644 index 000000000..8f1afe3dc --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/publishRecord.repository.ts @@ -0,0 +1,258 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: PublishRecord + */ +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { PublishStatus, PublishType } from '../enums' +import { PublishDayInfo, PublishInfo, PublishRecord } from '../schemas' +import { BaseRepository } from './base.repository' + +@Injectable() +export class PublishRecordRepository extends BaseRepository { + constructor( + @InjectModel(PublishRecord.name) + private readonly publishRecordModel: Model, + @InjectModel(PublishInfo.name) + private readonly publishInfoModel: Model, + @InjectModel(PublishDayInfo.name) + private readonly publishDayInfoModel: Model, + ) { + super(publishRecordModel) + } + + /** + * 创建 + * @param data + * @returns + */ + override async create(data: Partial) { + const res = await this.publishRecordModel.create(data) + return res + } + + /** + * 获取发布记录列表 + * @param query + * @returns + */ + async getPublishRecordList( + query: { + userId: string + accountId?: string + accountType?: AccountType + status?: PublishStatus + type?: PublishType + time?: [Date, Date] + uid?: string + }, + ): Promise { + const filters: RootFilterQuery = { + userId: query.userId, + ...(query.accountId !== undefined && { accountId: query.accountId }), + ...(query.accountType !== undefined && { + accountType: query.accountType, + }), + ...(query.status !== undefined && { + status: query.status, + }), + ...(query.type !== undefined && { type: query.type }), + ...(query.time !== undefined + && query.time.length === 2 && { + publishTime: { $gte: query.time[0], $lte: query.time[1] }, + }), + ...(query.uid !== undefined && { uid: query.uid }), + } + const db = this.publishRecordModel.find(filters).sort({ + createdAt: -1, + }) + const list = await db.exec() + + return list + } + + // 获取发布记录信息 + async getPublishRecordInfo(id: string) { + return this.publishRecordModel.findOne({ _id: id }) + } + + // 删除发布记录 + async deletePublishRecordById(id: string): Promise { + const res = await this.publishRecordModel.deleteOne({ _id: id }) + return res.deletedCount > 0 + } + + // 更新 + async updatePublishRecord( + filter: RootFilterQuery, + data: Partial, + ) { + const res = await this.publishRecordModel.updateOne(filter, { $set: data }) + return res.modifiedCount > 0 + } + + /** + * 创建 + * @param data + * @returns + */ + async createPublishInfo(data: Partial) { + const res = await this.publishInfoModel.create(data) + return res + } + + /** + * change day publish info + * if data had publish record, update it + * @param data + */ + async upDayPublishInfo(data: PublishRecord) { + const today = new Date() + this.publishDayInfoModel + .findOneAndUpdate( + { + userId: data.userId, + createdAt: { + $gte: new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ), + $lt: new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + 1, + ), + }, + }, + { + $inc: { publishTotal: 1 }, + }, + { + upsert: true, + new: true, + }, + ) + .exec() + } + + /** + * 获取发布每日信息列表 + * @param inFilter + * @param pageInfo + * @returns + */ + async getPublishDayInfoList( + inFilter: { + userId: string + time?: [Date, Date] + }, + pageInfo: { + pageNo: number + pageSize: number + }, + ) { + const { pageNo, pageSize } = pageInfo + const filter: RootFilterQuery = { + userId: inFilter.userId, + ...(inFilter.time && { + createdAt: { $gte: inFilter.time[0], $lte: inFilter.time[1] }, + }), + } + + const total = await this.publishDayInfoModel.countDocuments(filter) + const list = await this.publishDayInfoModel + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo! - 1) * pageSize) + .limit(pageSize) + .lean() + + return { + total, + list, + } + } + + // 发放发布奖励 + async findUserRecordInfo(userId: string) { + // 1. 查询发放状态 + const recordInfo = await this.publishInfoModel.findOne({ + userId, + }) + return recordInfo + } + + // 获取发布信息数据 + async getPublishInfoData(userId: string) { + const res = await this.publishInfoModel.findOne({ userId }) + return res + } + + async updateUserPublishInfo(userId: string, data: Partial) { + const res = await this.publishInfoModel.updateOne({ userId }, { + $set: data, + }) + return res + } + + // 根据获取发布记录信息 + async getPublishRecordByDataId(accountType: AccountType, dataId: string) { + const res = await this.publishInfoModel.findOne({ accountType, dataId }) + return res + } + + async getPublishRecordDetail(data: { + flowId: string + userId: string + }) { + const publishRecord = await this.publishRecordModel.findOne({ + flowId: data.flowId, + userId: data.userId, + }) + return publishRecord + } + + async getPublishRecordByTaskId(taskId: string, userId: string) { + const res = await this.publishRecordModel + .findOne({ taskId, userId }) + .sort({ createdAt: -1 }) + return res + } + + async getPublishRecordByDataIdAndUid(uid: string, dataId: string) { + const res = await this.publishRecordModel + .findOne({ uid, dataId }) + .sort({ createdAt: -1 }) + return res + } + + /** + * 根据用户任务ID获取发布记录 + * @param userTaskId + * @returns + */ + async getPublishRecordToUserTask(userTaskId: string) { + const res = await this.publishRecordModel + .findOne({ userTaskId }) + .sort({ createdAt: -1 }) + return res + } + + // 完成发布 + async donePublishRecord( + filter: { dataId: string, uid: string }, + data: { + workLink?: string + dataOption?: unknown + }, + ) { + const res = await this.publishRecordModel.findOneAndUpdate(filter, { $set: data }) + return res + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/subscription.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/subscription.repository.ts new file mode 100644 index 000000000..19951ff52 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/subscription.repository.ts @@ -0,0 +1,88 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { ICurrency, IPayment, ISubscriptionStatus } from '@yikart/stripe' +import { FilterQuery, Model } from 'mongoose' +import { Subscription } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListSubscriptionParams extends Pagination { + userId?: string + customer?: string + status?: ISubscriptionStatus + currency?: ICurrency + createdAt?: Date[] + search?: string +} + +export class SubscriptionRepository extends BaseRepository { + constructor( + @InjectModel(Subscription.name) subscriptionModel: Model, + ) { + super(subscriptionModel) + } + + async listWithPagination(params: ListSubscriptionParams) { + const { page, pageSize, userId, customer, status, currency, createdAt, search } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (customer) + filter.customer = customer + if (status) + filter.status = status + if (currency) + filter.currency = currency + if (createdAt) { + filter.created = { + $gte: Math.floor(createdAt[0].getTime() / 1000), + $lte: Math.floor(createdAt[1].getTime() / 1000), + } + } + if (search) { + const searchExample = { + $regex: search, + $options: 'i', + } + filter.$or = [ + { id: searchExample }, + { userId: searchExample }, + ] + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { created: -1 } }, + }) + } + + async listByUserId(userId: string) { + return await this.find({ userId }, { sort: { created: -1 } }) + } + + async getByUserIdAndStatus(userId: string, status: ISubscriptionStatus) { + return await this.findOne({ userId, status }) + } + + async getByIdAndStatus(id: string, status: ISubscriptionStatus) { + return await this.findOne({ id, status }) + } + + async upsertById(id: string, data: Partial) { + return await this.model.findOneAndUpdate({ id }, { $set: data }, { upsert: true, new: true }).exec() + } + + async upsertByIdAndStatus(id: string, status: ISubscriptionStatus, data: Partial) { + return await this.model.findOneAndUpdate({ id }, { $set: data }, { upsert: true, new: true }).exec() + } + + async upsertByUserIdAndStatus(userId: string, status: ISubscriptionStatus, data: Partial) { + return await this.model.findOneAndUpdate({ userId }, { $set: data }, { upsert: true, new: true }).exec() + } + + async getSubscribeByUserId(userId: string) { + return await this.findOne({ userId, status: ISubscriptionStatus.active, payment: { $in: [IPayment.year, IPayment.month] } }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet-account.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet-account.repository.ts new file mode 100644 index 000000000..c394e71e7 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet-account.repository.ts @@ -0,0 +1,83 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { Model, RootFilterQuery } from 'mongoose' +import { WalletAccountType } from '../enums' +import { UserWalletAccount } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListUserWalletAccountParams extends Pagination { + userId?: string + type?: string + isDef?: boolean +} + +export interface ListUserWalletAccountByUserIdParams { + type?: string + isDef?: boolean +} + +export interface ListUserWalletAccountByTypeParams { + type: string + isDef?: boolean +} + +export class UserWalletAccountRepository extends BaseRepository { + constructor( + @InjectModel(UserWalletAccount.name) private readonly userWalletAccountModel: Model, + ) { + super(userWalletAccountModel) + } + + async delete(id: string): Promise { + const res = await this.userWalletAccountModel.deleteOne({ _id: id }).exec() + return res.deletedCount > 0 + } + + async update(id: string, update: Partial): Promise { + const res = await this.userWalletAccountModel.updateOne({ _id: id }, { $set: update }).exec() + return res.modifiedCount === 1 + } + + async infoWithSame(userId: string, type: WalletAccountType, account: string) { + return this.userWalletAccountModel.findOne({ userId, type, account }).exec() + } + + async info(id: string) { + return this.userWalletAccountModel.findOne({ _id: id }).exec() + } + + async list(pageInfo: { + pageNo: number + pageSize: number + }, query: { + userId?: string + }) { + const { pageSize, pageNo } = pageInfo + const { userId } = query + const filter: RootFilterQuery = { + ...(userId !== undefined && { userId }), + } + + const [list, total] = await Promise.all([ + this.userWalletAccountModel + .find(filter) + .skip(pageNo! > 0 ? (pageNo! - 1) * pageSize : 0) + .limit(pageSize) + .exec(), + this.userWalletAccountModel.countDocuments(filter), + ]) + + return { + list, + total, + } + } + + // 获取总数 + async countOfUser(userId: string) { + const filter: RootFilterQuery = { + userId, + } + return this.userWalletAccountModel.countDocuments(filter) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet.repository.ts new file mode 100644 index 000000000..1fc2f1d70 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user-wallet.repository.ts @@ -0,0 +1,65 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { Decimal128 } from 'mongodb' +import { FilterQuery, Model } from 'mongoose' +import { UserWallet } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListUserWalletParams extends Pagination { + userId?: string +} + +export class UserWalletRepository extends BaseRepository { + constructor( + @InjectModel(UserWallet.name) private readonly userWalletModel: Model, + ) { + super(userWalletModel) + } + + async listWithPagination(params: ListUserWalletParams) { + const { page, pageSize, userId } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async getByUserId(userId: string): Promise { + return await this.findOne({ userId }) + } + + async updateBalanceByUserId(userId: string, balance: Decimal128): Promise { + return await this.updateOne({ userId }, { balance }) + } + + async updateIncomeByUserId(userId: string, income: Decimal128): Promise { + return await this.updateOne({ userId }, { income }) + } + + async incrementBalanceByUserId(userId: string, amount: Decimal128): Promise { + return await this.model.findOneAndUpdate( + { userId }, + { $inc: { balance: amount } }, + { new: true, upsert: true }, + ).exec() + } + + async incrementIncomeByUserId(userId: string, amount: Decimal128): Promise { + return await this.model.findOneAndUpdate( + { userId }, + { $inc: { income: amount } }, + { new: true, upsert: true }, + ).exec() + } + + async deleteByUserId(userId: string): Promise { + await this.deleteOne({ userId }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/user.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user.repository.ts new file mode 100644 index 000000000..686c918bd --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/user.repository.ts @@ -0,0 +1,146 @@ +import * as crypto from 'node:crypto' +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model } from 'mongoose' +import { UserStatus } from '../enums' +import { User } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListUserParams extends Pagination { + status?: UserStatus + mail?: string + popularizeCode?: string + createdAt?: string[] + keyword?: string +} + +export class UserRepository extends BaseRepository { + constructor( + @InjectModel(User.name) + userModel: Model, + ) { + super(userModel) + } + + async getUserInfoById(id: string) { + let userInfo + try { + const db = this.model.findById(id) + db.select('+password +salt') + userInfo = await db.exec() + } + catch { + return null + } + return userInfo + } + + async getUserInfoByMail(mail: string, all = false): Promise { + const db = this.model.findOne({ + mail, + isDelete: false, + }) + if (all) + db.select('+password +salt') + const userInfo = await db.exec() + + return userInfo + } + + async getUserByPopularizeCode(popularizeCode: string): Promise { + const userInfo = await this.model.findOne({ + popularizeCode, + status: UserStatus.OPEN, + isDelete: false, + }) + return userInfo + } + + async updateUserInfo(id: string, newData: Partial): Promise { + const res = await this.model.updateOne( + { _id: id }, + { $set: { ...newData } }, + ) + return res.modifiedCount > 0 + } + + async updateUserStatus(id: string, status: UserStatus): Promise { + const res = await this.model.updateOne( + { _id: id }, + { $set: { status } }, + ) + return res.modifiedCount > 0 + } + + async deleteUser(id: string): Promise { + const res = await this.model.updateOne( + { _id: id }, + { $set: { isDelete: true } }, + ) + return res.modifiedCount > 0 + } + + /** + * 生成推广码 + * @param userInfo + * @returns + */ + async generateUsePopularizeCode(userInfo: User) { + const phoneHash = crypto + .createHash('sha256') + .update(userInfo.mail) + .digest('hex') + .substring(0, 16) + + const combinedSalt = `aitoearn${phoneHash}` + + const hash = crypto + .createHash('sha256') + .update(userInfo.id) + .update(combinedSalt) + .digest('hex') + + // 取部分哈希值转换为5位代码 + const numericValue = Number.parseInt(hash.substring(0, 6), 16) + const code = numericValue + .toString(36) + .slice(-5) + .toUpperCase() + .padStart(5, '0') + + // 更新用户的推广码 + await this.model.updateOne( + { _id: userInfo.id }, + { $set: { popularizeCode: code } }, + ) + + return code + } + + /** + * 获取游标 + * @param condition + * @param tag + * @returns + */ + getCursor(condition: FilterQuery, tag: string) { + return this.model + .find(condition, tag) + .cursor() + } + + async setTotalStorage(userId: string, totalStorage: number, expiredAt?: Date): Promise { + const res = await this.model.updateOne( + { _id: userId }, + { + $set: { + storage: { + total: totalStorage, + expiredAt, + }, + }, + }, + ) + return res.modifiedCount > 0 + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/vip.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/vip.repository.ts new file mode 100644 index 000000000..2526bcc89 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/vip.repository.ts @@ -0,0 +1,71 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Model } from 'mongoose' +import { VipStatus } from '../enums' +import { User, UserVipInfo } from '../schemas' +import { BaseRepository } from './base.repository' + +export class VipRepository extends BaseRepository { + constructor( + @InjectModel(User.name) + private readonly userModel: Model, + ) { + super(userModel) + } + + async updateInfo( + user: User, + vipInfo: UserVipInfo, + ): Promise { + const res = await this.userModel.updateOne( + { + _id: user.id, + }, + { + $set: { + vipInfo, + }, + }, + ) + + return res.modifiedCount > 0 + } + + async updateVipStatus(userId: string, status: VipStatus): Promise { + const res = await this.userModel.updateOne( + { + _id: userId, + }, + { + $set: { + 'vipInfo.status': status, + }, + }, + ) + return res.modifiedCount > 0 + } + + async clearVipInfo(userId: string): Promise { + const res = await this.userModel.updateOne( + { + _id: userId, + }, + { + $set: { + vipInfo: null, + }, + }, + ) + return res.modifiedCount > 0 + } + + /** + * 查询当前有效的VIP会员列表 + * @returns + */ + async findAllNormelVipUsers(): Promise { + const now = new Date() + return this.userModel.find({ + 'vipInfo.expireTime': { $gt: now }, + }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/repositories/withdraw-record.repository.ts b/project/aitoearn-monorepo/libs/mongodb/src/repositories/withdraw-record.repository.ts new file mode 100644 index 000000000..ccbe33964 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/repositories/withdraw-record.repository.ts @@ -0,0 +1,143 @@ +import { InjectModel } from '@nestjs/mongoose' +import { Pagination } from '@yikart/common' +import { FilterQuery, Model, PipelineStage, RootFilterQuery } from 'mongoose' +import { WithdrawRecordStatus } from '../enums' +import { WithdrawRecord } from '../schemas' +import { BaseRepository } from './base.repository' + +export interface ListWithdrawRecordParams extends Pagination { + userId?: string + status?: number + amount?: number + createdAt?: Date[] +} + +export class WithdrawRecordRepository extends BaseRepository { + constructor( + @InjectModel(WithdrawRecord.name) withdrawRecordModel: Model, + ) { + super(withdrawRecordModel) + } + + getInfoById(id: string) { + return this.model.findById(id) + } + + // 获取信息 + getInfoByIncomeId(incomeRecordId: string) { + return this.model.findOne({ incomeRecordId }) + } + + async getListOfUser(page: { + pageNo: number + pageSize: number + }, query: { userId: string }) { + const { pageNo, pageSize } = page + const filter: RootFilterQuery = { + userId: query.userId, + } + + const [list, total] = await Promise.all([ + this.model + .find(filter) + .sort({ createdAt: -1 }) + .skip((pageNo - 1) * pageSize) + .limit(pageSize) + .exec(), + this.model.countDocuments(filter), + ]) + + return { + list, + total, + } + } + + async listWithPagination(params: ListWithdrawRecordParams) { + const { page, pageSize, userId, status, amount, createdAt } = params + + const filter: FilterQuery = {} + if (userId) + filter.userId = userId + if (status !== undefined) + filter.status = status + if (amount !== undefined) + filter.amount = amount + if (createdAt) { + filter.createdAt = { + $gte: createdAt[0], + $lte: createdAt[1], + } + } + + return await this.findWithPagination({ + page, + pageSize, + filter, + options: { sort: { createdAt: -1 } }, + }) + } + + async getByIncomeRecordId(incomeRecordId: string) { + return await this.findOne({ incomeRecordId }) + } + + async listWithAggregation(pipeline: PipelineStage[]) { + return await this.model.aggregate(pipeline).exec() + } + + async countByFilter(filter: FilterQuery) { + return await this.count(filter) + } + + // 发放提现 + release(id: string, data: { desc?: string, screenshotUrls?: string[], status?: WithdrawRecordStatus }) { + return this.model.updateOne({ _id: id }, { $set: data }) + } + + async getList(page: { + pageNo: number + pageSize: number + }, query: { userId?: string, status?: WithdrawRecordStatus }) { + const { pageNo, pageSize } = page + const filter: RootFilterQuery = { + ...(query.userId && { userId: query.userId }), + ...(query.status !== undefined && { status: query.status }), + } + + const [list, total] = await Promise.all([ + this.model + .aggregate([ + { $match: filter }, + { + $addFields: { + statusOrder: { + $switch: { + branches: [ + { case: { $eq: ['$status', 0] }, then: 1 }, // WAIT 排第一 + { case: { $eq: ['$status', 1] }, then: 2 }, // SUCCESS 排第二 + { case: { $eq: ['$status', -1] }, then: 3 }, // FAIL 排第三 + ], + default: 4, + }, + }, + }, + }, + { $sort: { statusOrder: 1, createdAt: -1 } }, + { $skip: (pageNo - 1) * pageSize }, + { $limit: pageSize }, + { + $project: { + statusOrder: 0, + }, + }, // 移除辅助字段 + ]), + this.model.countDocuments(filter), + ]) + + return { + list, + total, + } + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/account.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/account.schema.ts new file mode 100644 index 000000000..efa56482c --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/account.schema.ts @@ -0,0 +1,170 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import { Schema as MongooseSchema } from 'mongoose' + +import { WithTimestampSchema } from './timestamp.schema' + +export enum AccountStatus { + NORMAL = 1, // 可用 + ABNORMAL = 0, // 不可用 +} + +@Schema({ + collection: 'account', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Account extends WithTimestampSchema { + @Prop({ type: MongooseSchema.Types.String }) + _id: string + + id: string + + @Prop({ + required: true, + type: String, + index: true, + }) + userId: string + + @Prop({ + required: true, + enum: AccountType, + index: true, + }) + type: AccountType + + @Prop({ + required: true, // 平台账户的唯一ID˝ + index: true, + }) + uid: string + + @Prop({ + required: false, // 部分平台的补充ID + index: true, + }) + account: string + + @Prop({ + required: false, + type: String, + }) + loginCookie: string + + @Prop({ + required: false, + type: String, + }) + access_token: string + + @Prop({ + required: false, + type: String, + }) + refresh_token: string + + @Prop({ + required: false, + type: String, + default: '', + }) + token: string // 其他token 目前抖音用 + + @Prop({ + required: false, + type: Date, + index: true, + }) + loginTime?: Date + + @Prop({ + required: false, + }) + avatar?: string + + @Prop({ + required: true, + }) + nickname: string + + @Prop({ + required: true, + default: 0, + }) + fansCount: number + + @Prop({ + required: true, + default: 0, + }) + readCount: number + + @Prop({ + required: true, + default: 0, + }) + likeCount: number + + @Prop({ + required: true, + default: 0, + }) + collectCount: number + + @Prop({ + required: true, + default: 0, + }) + forwardCount: number + + @Prop({ + required: true, + default: 0, + }) + commentCount: number + + @Prop({ + required: false, + type: Date, + }) + lastStatsTime?: Date + + @Prop({ + required: true, + default: 0, + }) + workCount: number + + @Prop({ + required: true, + default: 0, + }) + income: number + + // 账户关联组,与 accountGroup.id 关联 + @Prop({ type: String, required: true }) + groupId: string + + @Prop({ + required: true, + default: AccountStatus.NORMAL, + index: true, + }) + status: AccountStatus // 登录状态,用于判断是否失效 + + @Prop({ type: String, required: false }) + channelId: string + + // 排序 + @Prop({ + required: true, + type: Number, + default: 1, + }) + rank: number +} + +export const AccountSchema = SchemaFactory.createForClass(Account) +AccountSchema.index({ type: 1, uid: 1 }, { unique: true }) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/accountGroup.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/accountGroup.schema.ts new file mode 100644 index 000000000..c92764597 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/accountGroup.schema.ts @@ -0,0 +1,70 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'accountGroup', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class AccountGroup extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + type: String, + }) + userId: string + + // 是否为默认用户组 + @Prop({ + required: true, + type: Boolean, + default: false, + }) + isDefault: boolean + + @Prop({ + required: false, + type: String, + }) + ip?: string + + @Prop({ + required: false, + type: String, + }) + location?: string + + // 代理IP + @Prop({ + required: false, + type: String, + default: '', + }) + proxyIp: string + + // 组名称 + @Prop({ + required: true, + type: String, + }) + name: string + + // json 指纹浏览器配置 + @Prop({ + required: false, + type: Object, + }) + browserConfig?: Record + + // 组排序 + @Prop({ + required: true, + type: Number, + default: 1, + }) + rank: number +} + +export const AccountGroupSchema = SchemaFactory.createForClass(AccountGroup) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/ai-log.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/ai-log.schema.ts new file mode 100644 index 000000000..88dfc6fc4 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/ai-log.schema.ts @@ -0,0 +1,98 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { AiLogChannel, AiLogStatus, AiLogType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'aiLogs', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class AiLog extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + enum: UserType, + }) + userType: UserType + + @Prop({ + required: false, + index: true, + }) + taskId?: string + + @Prop({ + required: true, + enum: AiLogType, + }) + type: AiLogType + + @Prop({ + required: true, + }) + model: string + + @Prop({ + required: true, + enum: AiLogChannel, + }) + channel: AiLogChannel + + @Prop({ + required: false, + }) + action?: string + + @Prop({ + required: true, + enum: AiLogStatus, + }) + status: AiLogStatus + + @Prop({ + required: true, + type: Date, + }) + startedAt: Date + + @Prop({ + required: false, + }) + duration?: number + + @Prop({ + required: true, + type: Object, + }) + request: Record + + @Prop({ + required: false, + type: Object, + }) + response?: Record + + @Prop({ + required: false, + type: Object, + }) + errorMessage?: string + + @Prop({ + required: true, + }) + points: number +} + +export const AiLogSchema = SchemaFactory.createForClass(AiLog) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-config.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-config.schema.ts new file mode 100644 index 000000000..c4ffac62f --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-config.schema.ts @@ -0,0 +1,50 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2024-11-22 09:53:38 + * @LastEditors: nevin + * @Description: 配置 AppConfigs appConfigs + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'appConfigs', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class AppConfig extends WithTimestampSchema { + id: string + + @Prop({ required: true, comment: '应用标识' }) + appId: string + + @Prop({ comment: '配置键名' }) + key: string + + @Prop({ + comment: '配置值', + type: Object, + }) + value: Record + + @Prop({ + comment: '配置描述', + default: '', + }) + description?: string + + @Prop({ + comment: '是否启用', + default: true, + }) + enabled: boolean + + @Prop({ type: Object }) + metadata?: Record +} + +export const AppConfigSchema = SchemaFactory.createForClass(AppConfig) +AppConfigSchema.index({ appId: 1, key: 1 }, { unique: true }) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-release.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-release.schema.ts new file mode 100644 index 000000000..37b491ad3 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/app-release.schema.ts @@ -0,0 +1,49 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AppPlatform } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class AppReleaseLinks { + @Prop({ required: false, comment: '商店链接' }) + store?: string + + @Prop({ required: true, comment: '直接下载链接' }) + direct: string +} + +@Schema({ + collection: 'app_releases', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class AppRelease extends WithTimestampSchema { + id: string + + @Prop({ required: true, comment: '平台' }) + platform: AppPlatform + + @Prop({ required: true, comment: '版本号' }) + version: string + + @Prop({ required: true, comment: '构建号' }) + buildNumber: number + + @Prop({ required: true, comment: '强制更新' }) + forceUpdate: boolean + + @Prop({ required: true, comment: '版本说明' }) + notes: string + + @Prop({ required: true, comment: '版本链接', type: AppReleaseLinks }) + links: AppReleaseLinks + + @Prop({ required: true, comment: '发布时间' }) + publishedAt: Date +} + +export const AppReleaseSchema = SchemaFactory.createForClass(AppRelease) +AppReleaseSchema.index({ platform: -1, buildNumber: -1 }, { unique: true }) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/blog.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/blog.schema.ts new file mode 100644 index 000000000..c69a68bf2 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/blog.schema.ts @@ -0,0 +1,20 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'blog', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Blog extends WithTimestampSchema { + id: string + + @Prop({ + comment: '内容', + default: '', + }) + content: string +} + +export const BlogSchema = SchemaFactory.createForClass(Blog) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/browser-profile.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/browser-profile.schema.ts new file mode 100644 index 000000000..3b348045f --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/browser-profile.schema.ts @@ -0,0 +1,37 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'browser_profiles', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class BrowserProfile extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + accountId: string + + @Prop({ + required: true, + }) + profileId: string + + @Prop({ + required: false, + }) + cloudSpaceId?: string + + @Prop({ + required: true, + type: Object, + }) + config: Record +} + +export const BrowserProfileSchema = SchemaFactory.createForClass(BrowserProfile) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/checkout.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/checkout.schema.ts new file mode 100644 index 000000000..c5907184a --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/checkout.schema.ts @@ -0,0 +1,149 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { ICheckoutMode, ICheckoutStatus, ICurrency, IPayment, Stripe } from '@yikart/stripe' + +export interface CheckoutMetadata { + userId: string + payment: IPayment + mode: ICheckoutMode + [key: string]: any +} + +@Schema({ + collection: 'checkout', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Checkout { + @Prop({ + type: String, + required: true, + index: true, + unique: true, + }) + id: string + + @Prop({ + type: String, + required: false, + index: true, + }) + userId: string + + @Prop({ + type: String, + required: false, + index: true, + }) + customer: string + + @Prop({ + type: String, + required: false, + index: true, + }) + payment_intent: string | null + + @Prop({ type: String, required: false, index: true }) + charge?: string // 订单chargeId + + @Prop({ + type: String, + required: false, + index: true, + default: null, + }) + subscription: string | null + + @Prop({ + type: String, + required: true, + index: true, + }) + mode: ICheckoutMode + + @Prop({ + type: String, + required: false, + index: true, + }) + price: string + + @Prop({ + type: String, + required: false, + }) + success_url: string + + @Prop({ + type: String, + required: false, + }) + currency: ICurrency + + @Prop({ + type: Number, + required: true, + default: ICheckoutStatus.created, + }) + status: ICheckoutStatus + + @Prop({ + type: Number, + required: true, + }) + amount: number // 收款金额 + + @Prop({ + type: Number, + required: true, + }) + amount_total: number // 实付金额 + + @Prop({ + type: Number, + required: false, + }) + quantity: number // 购买数目 + + @Prop({ + type: Number, + required: false, + default: 0, + }) + amount_refunded: number // 已退款金额 + + @Prop({ + type: String, + required: false, + index: true, + }) + url: string // 支付连接 + + @Prop({ + type: Number, + required: false, + }) + created: number // 支付连接创建时间 + + @Prop({ + type: Number, + required: false, + }) + expires_at: number // 支付连接过期时间 + + @Prop({ type: Object, required: false, default: {} }) + info?: object | null // 订单的完整信息 + + @Prop({ type: Object, required: false, default: {} }) + chargeInfo?: Stripe.Charge | null // charge的完整信息 + + @Prop({ type: Object, required: false, default: {} }) + metadata: CheckoutMetadata // 元数据 + + @Prop({ type: Object, required: false, default: {} }) + customer_details?: object | null // 客户的完整信息 +} + +export const CheckoutSchema = SchemaFactory.createForClass(Checkout) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/cloud-space.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/cloud-space.schema.ts new file mode 100644 index 000000000..299bef0e7 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/cloud-space.schema.ts @@ -0,0 +1,56 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { CloudSpaceRegion, CloudSpaceStatus } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'cloud_spaces', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class CloudSpace extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + accountGroupId: string + + @Prop({ + required: true, + }) + instanceId: string + + @Prop({ + required: true, + }) + region: CloudSpaceRegion + + @Prop({ + required: true, + }) + status: CloudSpaceStatus + + @Prop({ + required: true, + }) + ip: string + + @Prop() + password?: string + + @Prop({ + type: Date, + required: true, + }) + expiredAt: Date +} + +export const CloudSpaceSchema = SchemaFactory.createForClass(CloudSpace) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/coupon.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/coupon.schema.ts new file mode 100644 index 000000000..8f2122a07 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/coupon.schema.ts @@ -0,0 +1,26 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { ICurrency, IDuration } from '@yikart/stripe' +import { Int32 } from 'mongodb' + +@Schema({ + collection: 'coupon', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Coupon { + @Prop({ type: String, required: true, index: true, unique: true }) + id: string + + @Prop({ type: String, required: true, index: true }) + duration: IDuration + + @Prop({ type: String, required: false, default: ICurrency.usd }) + currency?: ICurrency // 货币 + + @Prop({ type: Int32, required: false }) + created?: number // 价格 正整数 每种货币的最小单位 1美刀的话对应 100 1元的话对应100 1英镑对应 100 +} + +export const CouponSchema = SchemaFactory.createForClass(Coupon) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/feedback.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/feedback.schema.ts new file mode 100644 index 000000000..d66920238 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/feedback.schema.ts @@ -0,0 +1,54 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2024-11-22 09:53:38 + * @LastEditors: nevin + * @Description: 反馈 Feedback feedback + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { FeedbackType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'feedback', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Feedback extends WithTimestampSchema { + id: string + + @Prop({ required: true }) + userId: string + + @Prop({ comment: '用户名' }) + userName: string + + @Prop({ + comment: '内容', + default: '', + }) + content: string + + @Prop({ + default: FeedbackType.feedback, + enum: FeedbackType, + }) + type: FeedbackType + + @Prop({ + comments: '标识数组', + type: [String], + default: [], + }) + tagList?: string[] + + @Prop({ + comments: '文件链接数组', + type: [String], + default: [], + }) + fileUrlList: string[] +} + +export const FeedbackSchema = SchemaFactory.createForClass(Feedback) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/income-record.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/income-record.schema.ts new file mode 100644 index 000000000..f7912a849 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/income-record.schema.ts @@ -0,0 +1,64 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { IncomeStatus, IncomeType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'incomeRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class IncomeRecord extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + amount: number + + @Prop({ + required: true, + enum: IncomeType, + }) + type: IncomeType + + @Prop({ + index: true, + enum: IncomeStatus, + default: IncomeStatus.WAIT, + }) + status: IncomeStatus + + // 提现ID + @Prop({ + required: false, + type: String, + }) + withdrawId?: string + + // 关联ID + @Prop({ + required: false, + type: String, + }) + relId?: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + type: Object, + required: false, + }) + metadata?: Record +} + +export const IncomeRecordSchema = SchemaFactory.createForClass(IncomeRecord) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/index.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/index.ts new file mode 100644 index 000000000..e16063537 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/index.ts @@ -0,0 +1,125 @@ +import { Account, AccountSchema } from './account.schema' +import { AccountGroup, AccountGroupSchema } from './accountGroup.schema' +import { AiLog, AiLogSchema } from './ai-log.schema' +import { AppConfig, AppConfigSchema } from './app-config.schema' +import { AppRelease, AppReleaseSchema } from './app-release.schema' +import { Blog, BlogSchema } from './blog.schema' +import { + BrowserProfile, + BrowserProfileSchema, +} from './browser-profile.schema' +import { Checkout, CheckoutSchema } from './checkout.schema' +import { + CloudSpace, + CloudSpaceSchema, +} from './cloud-space.schema' +import { Coupon, CouponSchema } from './coupon.schema' +import { Feedback, FeedbackSchema } from './feedback.schema' +import { + IncomeRecord, + IncomeRecordSchema, +} from './income-record.schema' +import { Manager, ManagerSchema } from './manager.schema' +import { Material, MaterialSchema } from './material.schema' +import { MaterialGroup, MaterialGroupSchema } from './materialGroup.schema' +import { MaterialTask, MaterialTaskSchema } from './materialTask.schema' +import { Media, MediaSchema } from './media.schema' +import { MediaGroup, MediaGroupSchema } from './mediaGroup.schema' +import { + MultiloginAccount, + MultiloginAccountSchema, +} from './multilogin-account.schema' +import { Notification, NotificationSchema } from './notification.schema' +import { + PointsRecord, + PointsRecordSchema, +} from './points-record.schema' +import { Price, PriceSchema } from './price.schema' +import { Product, ProductSchema } from './product.schema' +import { PublishDayInfo, PublishDayInfoSchema } from './publishDayInfo.schema' +import { PublishInfo, PublishInfoSchema } from './publishInfo.schema' +import { PublishRecord, PublishRecordSchema } from './publishRecord.schema' +import { Subscription, SubscriptionSchema } from './subscription.schema' +import { + UserWalletAccount, + UserWalletAccountSchema, +} from './user-wallet-account.schema' +import { + UserWallet, + UserWalletSchema, +} from './user-wallet.schema' +import { + User, + UserSchema, +} from './user.schema' +import { + WithdrawRecord, + WithdrawRecordSchema, +} from './withdraw-record.schema' + +export * from './account.schema' +export * from './accountGroup.schema' +export * from './ai-log.schema' +export * from './app-config.schema' +export * from './app-release.schema' +export * from './blog.schema' +export * from './browser-profile.schema' +export * from './checkout.schema' +export * from './cloud-space.schema' +export * from './coupon.schema' +export * from './feedback.schema' +export * from './income-record.schema' +export * from './manager.schema' +export * from './material.schema' +export * from './materialGroup.schema' +export * from './materialTask.schema' +export * from './media.schema' +export * from './mediaGroup.schema' +export * from './multilogin-account.schema' +export * from './notification.schema' +export * from './points-record.schema' +export * from './points-record.schema' +export * from './price.schema' +export * from './product.schema' +export * from './publishDayInfo.schema' +export * from './publishInfo.schema' +export * from './publishRecord.schema' +export * from './subscription.schema' +export * from './user-wallet-account.schema' +export * from './user-wallet.schema' +export * from './user.schema' +export * from './withdraw-record.schema' + +export const schemas = [ + { name: User.name, schema: UserSchema }, + { name: PointsRecord.name, schema: PointsRecordSchema }, + { name: IncomeRecord.name, schema: IncomeRecordSchema }, + { name: CloudSpace.name, schema: CloudSpaceSchema }, + { name: BrowserProfile.name, schema: BrowserProfileSchema }, + { name: MultiloginAccount.name, schema: MultiloginAccountSchema }, + { name: UserWalletAccount.name, schema: UserWalletAccountSchema }, + { name: UserWallet.name, schema: UserWalletSchema }, + { name: AiLog.name, schema: AiLogSchema }, + { name: AppConfig.name, schema: AppConfigSchema }, + { name: Blog.name, schema: BlogSchema }, + { name: Feedback.name, schema: FeedbackSchema }, + { name: Notification.name, schema: NotificationSchema }, + { name: Checkout.name, schema: CheckoutSchema }, + { name: Coupon.name, schema: CouponSchema }, + { name: Price.name, schema: PriceSchema }, + { name: Product.name, schema: ProductSchema }, + { name: Subscription.name, schema: SubscriptionSchema }, + { name: WithdrawRecord.name, schema: WithdrawRecordSchema }, + { name: AppRelease.name, schema: AppReleaseSchema }, + { name: Account.name, schema: AccountSchema }, + { name: AccountGroup.name, schema: AccountGroupSchema }, + { name: MediaGroup.name, schema: MediaGroupSchema }, + { name: Media.name, schema: MediaSchema }, + { name: Material.name, schema: MaterialSchema }, + { name: MaterialGroup.name, schema: MaterialGroupSchema }, + { name: MaterialTask.name, schema: MaterialTaskSchema }, + { name: Manager.name, schema: ManagerSchema }, + { name: PublishDayInfo.name, schema: PublishDayInfoSchema }, + { name: PublishInfo.name, schema: PublishInfoSchema }, + { name: PublishRecord.name, schema: PublishRecordSchema }, +] as const diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/manager.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/manager.schema.ts new file mode 100644 index 000000000..ebe432231 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/manager.schema.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +export enum ManagerStatus { + STOP = 0, // 停用 + OPEN = 1, // 正常 + DELETE = 2, // 删除 +} + +@Schema({ + collection: 'manager', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Manager extends WithTimestampSchema { + id: string + + @Prop({ required: true, unique: true }) + account: string + + @Prop({ required: true }) + password: string + + @Prop({ required: true }) + salt: string + + @Prop({ required: true }) + name: string + + @Prop({ default: ManagerStatus.OPEN }) + status: ManagerStatus + + @Prop() + avatar?: string + + @Prop() + mail?: string +} + +export const ManagerSchema = SchemaFactory.createForClass(Manager) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/material.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/material.schema.ts new file mode 100644 index 000000000..96bfaa470 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/material.schema.ts @@ -0,0 +1,148 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { FileMetadata, MediaType } from './media.schema' +import { WithTimestampSchema } from './timestamp.schema' + +export enum MaterialType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 +} + +export enum MaterialStatus { + WAIT = 0, + SUCCESS = 1, + FAIL = -1, +} + +@Schema({ + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class MaterialMedia { + @Prop({ + required: true, + }) + url: string + + @Prop({ + type: Object, + }) + metadata?: FileMetadata + + @Prop({ + required: true, + }) + type: MediaType + + @Prop({ + required: false, + default: '', + }) + content?: string + + @Prop({ + required: false, + }) + mediaId?: string +} +@Schema({ + collection: 'material', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class Material extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + index: true, + default: UserType.User, + }) + userType: UserType + + @Prop({ + required: true, + index: true, + }) + groupId: string // 所属组ID + + @Prop({ + required: false, + index: true, + }) + taskId?: string // 使用生成的任务ID + + @Prop({ + required: true, + enum: MaterialType, + index: true, + }) + type: MaterialType + + @Prop({ + required: false, + }) + coverUrl?: string + + @Prop({ + required: true, + type: [MaterialMedia], + default: [], + }) + mediaList: MaterialMedia[] + + @Prop({ + required: false, + }) + title?: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + required: false, + default: {}, + type: Object, // 明确指定类型为 Object + }) + option?: Record + + @Prop({ + required: true, + enum: MaterialStatus, + default: MaterialStatus.WAIT, + }) + status: MaterialStatus + + @Prop({ + required: false, + default: '', + }) + message?: string + + @Prop({ + required: true, + default: 0, + index: true, + }) + useCount: number + + // 是否自动删除素材 + @Prop({ + required: true, + default: false, + }) + autoDeleteMedia: boolean +} + +export const MaterialSchema = SchemaFactory.createForClass(Material) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialGroup.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialGroup.schema.ts new file mode 100644 index 000000000..7125a40fb --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialGroup.schema.ts @@ -0,0 +1,63 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-02-22 12:37:22 + * @LastEditors: nevin + * @Description: 素材库 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { MaterialType } from './material.schema' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'materialGroup', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class MaterialGroup extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + index: true, + default: UserType.User, + }) + userType: UserType + + @Prop({ + required: true, + index: true, + }) + name: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + required: true, + enum: MaterialType, + index: true, + }) + type: MaterialType + + // 是否默认 + @Prop({ + required: true, + index: true, + default: false, + }) + isDefault: boolean +} + +export const MaterialGroupSchema = SchemaFactory.createForClass(MaterialGroup) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialTask.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialTask.schema.ts new file mode 100644 index 000000000..3f58fd2bc --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/materialTask.schema.ts @@ -0,0 +1,163 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { SchemaTypes } from 'mongoose' +import { MaterialType } from './material.schema' +import { MediaType } from './media.schema' +import { WithTimestampSchema } from './timestamp.schema' + +export enum MaterialTaskStatus { + WAIT = 0, + RUNNING = 1, + SUCCESS = 2, + FAIL = -1, +} + +@Schema({ + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class MediaUrlInfo { + @Prop({ + required: true, + index: true, + }) + mediaId: string + + @Prop({ + required: true, + index: true, + }) + url: string + + @Prop({ + required: true, + index: true, + default: 0, + }) + num: number + + @Prop({ + required: true, + }) + type: MediaType +} + +@Schema({ + collection: 'materialTask', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class MaterialTask extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + index: true, + default: UserType.User, + }) + userType: UserType + + @Prop({ + required: true, + index: true, + }) + groupId: string // 所属组ID + + @Prop({ + required: true, + enum: MaterialType, + index: true, + }) + type: MaterialType + + @Prop({ + required: true, + }) + aiModelTag: string + + @Prop({ + required: true, + }) + prompt: string // 提示词 + + @Prop({ + required: false, + }) + coverGroup?: string + + // 使用的媒体组数组 + @Prop({ + type: [String], + default: [], + }) + mediaGroups: string[] + + @Prop({ + required: false, + default: {}, + type: Object, // 明确指定类型为 Object + }) + option?: Record // 高级设置 + + @Prop({ + required: false, + }) + title?: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + required: true, + type: [MediaUrlInfo], + }) + coverUrlList: MediaUrlInfo[] // 封面数组 + + @Prop({ + required: true, + type: SchemaTypes.Mixed, + }) + mediaUrlMap: MediaUrlInfo[][] // 媒体的二维数组 + + @Prop({ + required: true, + }) + reNum: number + + @Prop({ + required: false, + type: Number, + }) + textMax?: number + + @Prop({ + required: false, + }) + language?: string + + @Prop({ + required: true, + enum: MaterialTaskStatus, + default: MaterialTaskStatus.WAIT, + }) + status: MaterialTaskStatus + + @Prop({ + required: true, + default: false, + }) + autoDeleteMedia: boolean +} + +export const MaterialTaskSchema = SchemaFactory.createForClass(MaterialTask) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/media.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/media.schema.ts new file mode 100644 index 000000000..4124f1a51 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/media.schema.ts @@ -0,0 +1,91 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-02-22 12:37:22 + * @LastEditors: nevin + * @Description: 媒体 Media media + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { WithTimestampSchema } from './timestamp.schema' + +export enum MediaType { + VIDEO = 'video', // 视频 + IMG = 'img', // 图片 +} + +export interface FileMetadata { + size: number // bytes + mimeType: string +} + +@Schema({ + collection: 'media', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class Media extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + index: true, + default: UserType.User, + }) + userType: UserType + + @Prop({ + required: false, + index: true, + }) + groupId?: string // 所属组ID + + @Prop({ + required: true, + enum: MediaType, + index: true, + }) + type: MediaType + + @Prop({ + required: true, + }) + url: string + + @Prop({ + required: false, + }) + thumbUrl?: string // 缩略图 + + @Prop({ + required: false, + }) + title?: string + + @Prop({ + required: false, + }) + desc?: string + + @Prop({ + required: true, + default: 0, + }) + useCount: number + + @Prop({ + type: Object, + }) + metadata?: FileMetadata +} + +export const MediaSchema = SchemaFactory.createForClass(Media) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/mediaGroup.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/mediaGroup.schema.ts new file mode 100644 index 000000000..25da9db44 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/mediaGroup.schema.ts @@ -0,0 +1,63 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-02-22 12:37:22 + * @LastEditors: nevin + * @Description: 媒体库 mediaGroup + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { UserType } from '@yikart/common' +import { MediaType } from './media.schema' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'mediaGroup', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class MediaGroup extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + index: true, + }) + userId: string + + @Prop({ + required: true, + index: true, + default: UserType.User, + }) + userType: UserType + + @Prop({ + required: true, + enum: MediaType, + index: true, + }) + type: MediaType + + @Prop({ + required: true, + }) + title: string + + @Prop({ + required: false, + }) + desc?: string + + // 是否默认 + @Prop({ + required: true, + index: true, + default: false, + }) + isDefault: boolean +} + +export const MediaGroupSchema = SchemaFactory.createForClass(MediaGroup) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/multilogin-account.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/multilogin-account.schema.ts new file mode 100644 index 000000000..9ce61f8aa --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/multilogin-account.schema.ts @@ -0,0 +1,44 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'multilogin_accounts', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, + id: true, +}) +export class MultiloginAccount extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + email: string + + @Prop({ + required: true, + }) + password: string + + @Prop({ + type: Number, + required: true, + }) + maxProfiles: number + + @Prop({ + type: Number, + required: true, + }) + currentProfiles: number + + @Prop({ + type: String, + required: false, + }) + token?: string +} + +export const MultiloginAccountSchema = SchemaFactory.createForClass(MultiloginAccount) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/notification.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/notification.schema.ts new file mode 100644 index 000000000..02222d9d7 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/notification.schema.ts @@ -0,0 +1,88 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Types } from 'mongoose' +import * as mongoose from 'mongoose' +import { NotificationStatus, NotificationType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'notifications', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class Notification extends WithTimestampSchema { + id: string + + @Prop({ + type: Types.ObjectId, + required: true, + index: true, + }) + userId: string + + @Prop({ + type: String, + required: true, + }) + title: string + + @Prop({ + type: String, + required: true, + description: '通知内容摘要或全文', + }) + content: string + + @Prop({ + type: String, + enum: Object.values(NotificationType), + required: true, + description: '通知类型', + }) + type: NotificationType + + @Prop({ + type: String, + enum: Object.values(NotificationStatus), + default: NotificationStatus.Unread, + index: true, + description: '通知状态', + }) + status: NotificationStatus + + @Prop({ + type: Date, + required: false, + description: '用户标记为已读的时间', + }) + readAt?: Date + + @Prop({ + type: String, + required: true, + index: true, + description: '关联的相关ID(任务ID、用户任务ID等)', + }) + relatedId: string + + @Prop({ + type: mongoose.Schema.Types.Mixed, + required: false, + description: '任意数据,用于存储与通知相关的额外信息', + }) + data?: Record + + @Prop({ + type: Date, + required: false, + index: true, + description: '用户删除时间,存在即代表已删除', + }) + deletedAt?: Date +} + +export const NotificationSchema = SchemaFactory.createForClass(Notification) + +NotificationSchema.index({ userId: 1, status: 1 }) +NotificationSchema.index({ userId: 1, deletedAt: 1, createdAt: -1 }) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/points-record.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/points-record.schema.ts new file mode 100644 index 000000000..2a015ae40 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/points-record.schema.ts @@ -0,0 +1,69 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Document } from 'mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +export type PointsRecordDocument = PointsRecord & Document + +export enum IPointStatus { + FREE = 0, // 未被过期积分抵扣欧 + PART_DI_KOU = 1, // 部分被过期积分抵扣 + TOTAL_DI_KOU = 2, // 完全被过期积分抵扣 +} + +@Schema({ + collection: 'pointsRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class PointsRecord extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: true, + }) + amount: number + + @Prop({ + required: true, + }) + balance: number + + @Prop({ + required: true, + }) + type: string + + @Prop({ + required: false, + }) + description?: string + + // 这条积分记录是否已被过期积分抵扣 + @Prop({ + required: false, + default: IPointStatus.FREE, + }) + status: IPointStatus + + @Prop({ + required: false, + default: 0, + }) + usedForDiKou: number + + @Prop({ + type: Object, + required: false, + default: {}, + }) + metadata?: Record +} + +export const PointsRecordSchema = SchemaFactory.createForClass(PointsRecord) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/price.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/price.schema.ts new file mode 100644 index 000000000..1460fb259 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/price.schema.ts @@ -0,0 +1,39 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { ICurrency } from '@yikart/stripe' +import { Int32 } from 'mongodb' +/* + * @Author: white + * @Date: 2025-06-25 16:12:27 + * @LastEditTime: 2025-06-26 09:47:37 + * @LastEditors: white + * @Description: price + */ + +@Schema({ + collection: 'price', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Price { + @Prop({ type: String, required: true, index: true, unique: true }) + id: string + + @Prop({ type: String, required: true, index: true }) + product?: string // 关联产品id + + @Prop({ type: Boolean, required: false, index: true }) + active?: boolean + + @Prop({ type: String, required: false, default: ICurrency.usd }) + currency?: ICurrency // 货币 + + @Prop({ type: Object, required: false, default: {} }) + metadata?: object + + @Prop({ type: Int32, required: true }) + unit_amount?: number // 价格 正整数 每种货币的最小单位 1美刀的话对应 100 1元的话对应100 1英镑对应 100 +} + +export const PriceSchema = SchemaFactory.createForClass(Price) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/product.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/product.schema.ts new file mode 100644 index 000000000..5128678d0 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/product.schema.ts @@ -0,0 +1,48 @@ +/* + * @Author: white + * @Date: 2025-06-25 16:12:27 + * @LastEditTime: 2025-06-26 09:47:37 + * @LastEditors: white + * @Description: product + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' + +@Schema({ + collection: 'product', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Product { + @Prop({ + type: String, + required: true, + index: true, + unique: true, + }) + id: string + + @Prop({ + type: String, + required: true, + index: true, + unique: true, + }) + name: string + + @Prop({ + type: Boolean, + required: false, + index: true, + }) + active?: boolean + + @Prop({ type: Array, required: false }) + images?: string[] // 邀请人用户ID + + @Prop({ type: Object, required: false, default: {} }) + metadata?: object +} + +export const ProductSchema = SchemaFactory.createForClass(Product) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishDayInfo.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishDayInfo.schema.ts new file mode 100644 index 000000000..6ec1dbc22 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishDayInfo.schema.ts @@ -0,0 +1,36 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 每日发布信息 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'publishDayInfo', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class PublishDayInfo extends WithTimestampSchema { + @Prop({ + required: true, + index: true, + type: String, + default: '', + }) + userId: string + + // 发布总数 + @Prop({ + required: true, + type: Number, + default: 0, + }) + publishTotal: number +} + +export const PublishDayInfoSchema = SchemaFactory.createForClass(PublishDayInfo) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishInfo.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishInfo.schema.ts new file mode 100644 index 000000000..5ab4a55bd --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishInfo.schema.ts @@ -0,0 +1,42 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 发布信息 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'publishInfo', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class PublishInfo extends WithTimestampSchema { + @Prop({ + required: true, + index: true, + type: String, + default: '', + }) + userId: string + + @Prop({ + index: true, + required: true, + type: Date, + }) + upInfoDate: Date + + // 已经连续发布的天数 + @Prop({ + type: Number, + default: 0, + }) + days: number +} + +export const PublishInfoSchema = SchemaFactory.createForClass(PublishInfo) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishRecord.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishRecord.schema.ts new file mode 100644 index 000000000..d324940fe --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/publishRecord.schema.ts @@ -0,0 +1,177 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 发布记录 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { AccountType } from '@yikart/common' +import mongoose from 'mongoose' +import { PublishStatus, PublishType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'publishRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class PublishRecord extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: false, + }) + flowId?: string // 前端传入的流水ID + + @Prop({ + required: false, + type: String, + }) + userTaskId?: string // 用户任务ID + + @Prop({ + required: false, + type: String, + }) + taskId?: string // 任务ID + + @Prop({ + required: false, + type: String, + }) + taskMaterialId?: string // 任务素材ID + + @Prop({ + required: true, + enum: PublishType, + }) + type: PublishType + + @Prop({ + required: false, + }) + title?: string + + @Prop({ + required: false, + default: '', + }) + desc?: string // 主要内容 + + @Prop({ + required: true, + }) + accountId: string + + // 话题 + @Prop({ + required: true, + type: [String], + }) + topics: string[] + + @Prop({ + required: true, + }) + accountType: AccountType + + @Prop({ + required: true, + }) + uid: string + + @Prop({ + required: false, + }) + videoUrl?: string + + @Prop({ + required: false, + }) + coverUrl?: string + + // 图片列表 + @Prop({ + required: false, + type: [String], + }) + imgUrlList?: string[] + + @Prop({ + required: true, + type: Date, + index: true, + }) + publishTime: Date + + @Prop({ + required: true, + enum: PublishStatus, + default: PublishStatus.WaitingForPublish, + }) + status: PublishStatus + + // 错误信息 + @Prop({ + required: false, + }) + errorMsg?: string + + // 队列 ID + @Prop({ + required: false, + }) + queueId?: string + + // 此任务是否进入队列 + @Prop({ + required: true, + default: false, + }) + inQueue: boolean + + /** + * 任意对象值 + * bilibili: { + * tid: number; 分区 + * copyright: number; 1-原创,2-转载(转载时source必填) + * source: string; 如果copyright为转载,则此字段表示转载来源; + * } + */ + @Prop({ + required: false, + type: mongoose.Schema.Types.Mixed, + }) + option?: Record + + @Prop({ + required: true, + index: true, + type: String, + default: '', + }) + dataId: string // 微信公众号-publish_id + + @Prop({ + required: false, + type: String, + }) + workLink?: string // 作品链接 + + // 数据补充内容 + @Prop({ + required: false, + type: mongoose.Schema.Types.Mixed, + }) + dataOption?: Record +} + +export const PublishRecordSchema = SchemaFactory.createForClass(PublishRecord) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/subscription.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/subscription.schema.ts new file mode 100644 index 000000000..b07c625c8 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/subscription.schema.ts @@ -0,0 +1,67 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { ICurrency, IPayment, ISubscriptionStatus } from '@yikart/stripe' + +@Schema({ + collection: 'subscription', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Subscription { + @Prop({ + type: String, + required: true, + index: true, + unique: true, + }) + id: string + + @Prop({ + type: String, + required: false, + index: true, + }) + userId: string + + @Prop({ + type: String, + required: false, + index: true, + }) + customer: string + + @Prop({ + type: String, + required: false, + }) + currency: ICurrency + + @Prop({ + type: String, + required: false, + default: null, + }) + payment: IPayment + + @Prop({ + type: Number, + required: true, + default: ISubscriptionStatus.canceled, + }) + status: ISubscriptionStatus + + @Prop({ + type: Number, + required: false, + }) + created: number // 订阅开始时间 + + @Prop({ type: Object, required: false, default: {} }) + info?: object // 订阅的完整信息 + + @Prop({ type: Object, required: false, default: {} }) + metadata?: object | null // 元数据 +} + +export const SubscriptionSchema = SchemaFactory.createForClass(Subscription) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/timestamp.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/timestamp.schema.ts new file mode 100644 index 000000000..a8b57047b --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/timestamp.schema.ts @@ -0,0 +1,4 @@ +export class WithTimestampSchema { + createdAt: Date + updatedAt: Date +} diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet-account.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet-account.schema.ts new file mode 100644 index 000000000..ecdaeb839 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet-account.schema.ts @@ -0,0 +1,68 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-04-27 12:11:39 + * @LastEditors: nevin + * @Description: 用户钱包账户 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WalletAccountType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'userWalletAccount', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class UserWalletAccount extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: false, + }) + mail?: string // 邮箱 + + @Prop({ + required: false, + }) + userName?: string // 真实姓名 + + @Prop({ + required: true, + }) + account: string // 账号 + + @Prop({ + required: false, + }) + cardNum?: string // 身份证号 + + @Prop({ + required: false, + }) + phone?: string // 绑定的手机号 + + @Prop({ + required: true, + enum: WalletAccountType, + }) + type: WalletAccountType + + @Prop({ + required: true, + default: false, + }) + isDef: boolean // 是否默认 +} + +export const UserWalletAccountSchema + = SchemaFactory.createForClass(UserWalletAccount) + +UserWalletAccountSchema.index({ userId: 1, type: 1, account: 1 }, { unique: true }) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet.schema.ts new file mode 100644 index 000000000..25df16a0d --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user-wallet.schema.ts @@ -0,0 +1,41 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-03-24 20:56:59 + * @LastEditors: nevin + * @Description: 用户钱包 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { Decimal128 } from 'mongodb' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + collection: 'userWallet', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class UserWallet extends WithTimestampSchema { + @Prop({ + required: true, + }) + userId: string + + @Prop({ + type: Decimal128, + required: true, + default: 0, + }) + balance: Decimal128 // 余额 + + // 收入 + @Prop({ + type: Decimal128, + required: true, + default: 0, + }) + income: Decimal128 +} + +export const UserWalletSchema = SchemaFactory.createForClass(UserWallet) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/user.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user.schema.ts new file mode 100644 index 000000000..8fa5d2b0e --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/user.schema.ts @@ -0,0 +1,195 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { EarnInfoStatus, UserStatus, VipStatus } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class UserBackData { + @Prop({ + required: false, + }) + phone?: string + + @Prop({ required: false }) + wxOpenid?: string + + @Prop({ required: false }) + wxUnionid?: string +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class UserEarnInfo { + @Prop({ + required: true, + enum: EarnInfoStatus, + default: EarnInfoStatus.OPEN, + }) + status: EarnInfoStatus + + @Prop({ required: true }) + cycleInterval: number +} + +// 用户会员信息 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class UserVipInfo { + @Prop({ required: true }) + expireTime: Date + + @Prop({ + required: true, + enum: VipStatus, + default: VipStatus.none, + }) + status: VipStatus + + // 开通时间 + @Prop({ required: true }) + startTime: Date +} + +export class UserStorage { + @Prop({ + required: true, + default: 500 * 1024 * 1024, + }) + total: number // 总存储(Bytes) + + @Prop({ required: false }) + expiredAt?: Date +} + +@Schema({ + collection: 'user', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class User extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + default: '', + }) + name: string + + @Prop({ + required: false, + index: true, + }) + mail: string + + @Prop({ + required: false, + }) + avatar?: string + + @Prop({ + required: false, + }) + phone?: string + + @Prop({ + required: false, + select: false, + }) + password?: string + + @Prop({ + required: false, + select: false, + }) + salt?: string + + @Prop({ + required: true, + enum: UserStatus, + default: UserStatus.OPEN, + }) + status: UserStatus + + // 是否删除 + @Prop({ + required: true, + default: false, + index: true, + }) + isDelete: boolean + + @Prop({ required: false }) + wxOpenid?: string + + @Prop({ required: false }) + wxUnionid?: string + + @Prop({ required: false }) + popularizeCode?: string // 我的推广码 + + @Prop({ required: false }) + inviteUserId?: string // 邀请人用户ID + + @Prop({ required: false }) + inviteCode?: string // 我填写的邀请码 + + @Prop({ type: Object, required: false, default: {} }) + backData?: UserBackData + + @Prop({ type: Object, required: false, default: {} }) + earnInfo?: UserEarnInfo + + @Prop({ type: Object, required: false }) + googleAccount?: Record // Google账号信息 + + // 用户VIP会员信息 + @Prop({ type: UserVipInfo, required: false }) + vipInfo?: UserVipInfo + + @Prop({ + required: true, + default: 0, + }) + score: number // 积分 + + @Prop({ + required: true, + default: 0, + }) + income: number // 收入(分) + + // 累计收入 + @Prop({ + required: true, + default: 0, + }) + totalIncome: number + + @Prop({ + required: true, + default: 0, + }) + usedStorage: number // 已用存储(Bytes) + + @Prop({ type: UserStorage, required: true, default: { + total: 500 * 1024 * 1024, + } }) + storage: UserStorage + + // 累计收入 + @Prop({ + required: false, + default: 0, + }) + tenDayExpPoint: number +} + +export const UserSchema = SchemaFactory.createForClass(User) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/schemas/withdraw-record.schema.ts b/project/aitoearn-monorepo/libs/mongodb/src/schemas/withdraw-record.schema.ts new file mode 100644 index 000000000..17fa6c8c1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/schemas/withdraw-record.schema.ts @@ -0,0 +1,85 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { WithdrawRecordStatus, WithdrawRecordType } from '../enums' +import { WithTimestampSchema } from './timestamp.schema' +import { UserWalletAccount } from './user-wallet-account.schema' + +@Schema({ + collection: 'withdrawRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: true, +}) +export class WithdrawRecord extends WithTimestampSchema { + id: string + + @Prop({ + required: true, + }) + userId: string + + @Prop({ + required: false, + type: String, + }) + flowId?: string + + @Prop({ + required: false, + }) + userWalletAccountId?: string + + @Prop({ + required: false, + }) + userWalletAccountInfo?: UserWalletAccount + + @Prop({ + required: true, + enum: WithdrawRecordType, + }) + type: WithdrawRecordType + + @Prop({ + required: true, + type: Number, + }) + amount: number + + // 收入记录ID + @Prop({ + required: false, + type: String, + }) + incomeRecordId?: string + + // 关联ID + @Prop({ + required: false, + type: String, + }) + relId?: string + + @Prop({ + required: false, + }) + desc?: string // 备注 + + @Prop({ type: [String] }) + screenshotUrls?: string[] // 发放截图列表 + + @Prop({ + required: true, + enum: WithdrawRecordStatus, + default: WithdrawRecordStatus.WAIT, + }) + status: WithdrawRecordStatus + + @Prop({ + type: Object, + required: false, + }) + metadata?: Record +} + +export const WithdrawRecordSchema = SchemaFactory.createForClass(WithdrawRecord) diff --git a/project/aitoearn-monorepo/libs/mongodb/src/transactional.injector.ts b/project/aitoearn-monorepo/libs/mongodb/src/transactional.injector.ts new file mode 100644 index 000000000..7d8be6c74 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/src/transactional.injector.ts @@ -0,0 +1,93 @@ +import type { Injectable } from '@nestjs/common/interfaces' +import type { InstanceWrapper } from '@nestjs/core/injector/instance-wrapper' +import { Injectable as InjectableDec, Logger, OnModuleInit } from '@nestjs/common' +import { MetadataScanner, ModulesContainer } from '@nestjs/core' +import { InjectConnection } from '@nestjs/mongoose' +import { TransactionOptions } from 'mongodb' +import { Connection } from 'mongoose' +import { + TRANSACTIONAL_METADATA, +} from './decorators/transactional.decorator' + +/** + * 事务注入器 + * 在模块初始化时扫描所有带有 @Transactional 装饰器的方法, + * 并为这些方法注入事务处理逻辑 + */ +@InjectableDec() +export class TransactionalInjector implements OnModuleInit { + private readonly logger = new Logger(TransactionalInjector.name) + private readonly metadataScanner: MetadataScanner = new MetadataScanner() + + constructor( + private readonly modulesContainer: ModulesContainer, + @InjectConnection() private readonly connection: Connection, + ) {} + + async onModuleInit() { + for (const provider of this.getProviders()) { + this.injectToProvider(provider) + } + } + + private* getProviders(): Generator> { + for (const module of this.modulesContainer.values()) { + for (const provider of module.providers.values()) { + if (provider && provider.metatype?.prototype) { + yield provider as InstanceWrapper + } + } + } + } + + private injectToProvider(wrapper: InstanceWrapper): void { + const { metatype } = wrapper + if (!metatype) + return + + const prototype = metatype.prototype + const methodNames = this.metadataScanner.getAllMethodNames(prototype) + + for (const methodName of methodNames) { + const method = prototype[methodName] + if (this.isDecorated(method)) { + const options = this.getDecoratorOptions(method) + const wrappedMethod = this.wrapMethod(method, methodName, prototype.constructor.name, options) + this.reDecorate(method, wrappedMethod) + prototype[methodName] = wrappedMethod + this.logger.log(`Injected transaction to ${prototype.constructor.name}.${methodName}`) + } + } + } + + private isDecorated(target: object): boolean { + return Reflect.hasMetadata(TRANSACTIONAL_METADATA, target) + } + + private getDecoratorOptions(target: object): TransactionOptions { + return Reflect.getMetadata(TRANSACTIONAL_METADATA, target) + } + + private reDecorate(source: object, destination: object): void { + const keys = Reflect.getMetadataKeys(source) + for (const key of keys) { + const meta = Reflect.getMetadata(key, source) + Reflect.defineMetadata(key, meta, destination) + } + } + + private wrapMethod( + originalMethod: (...args: unknown[]) => unknown, + methodName: string, + className: string, + options: TransactionOptions, + ): (...args: unknown[]) => unknown { + return new Proxy(originalMethod, { + apply: async (target, thisArg, args: unknown[]) => { + this.logger.debug(`Executing transactional method: ${className}.${methodName}`) + + return this.connection.transaction(() => Reflect.apply(target, thisArg, args) as Promise, options) + }, + }) + } +} diff --git a/project/aitoearn-monorepo/libs/mongodb/tsconfig.json b/project/aitoearn-monorepo/libs/mongodb/tsconfig.json new file mode 100644 index 000000000..5a51c286d --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/mongodb/tsconfig.lib.json b/project/aitoearn-monorepo/libs/mongodb/tsconfig.lib.json new file mode 100644 index 000000000..abb4dc464 --- /dev/null +++ b/project/aitoearn-monorepo/libs/mongodb/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc" + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/multilogin/README.md b/project/aitoearn-monorepo/libs/multilogin/README.md new file mode 100644 index 000000000..90ac6cda0 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/README.md @@ -0,0 +1,243 @@ +# @yikart/multilogin + +NestJS 模块,用于 Multilogin X API 集成。 + +## 功能特性 + +- 🚀 **Launcher API**: 控制浏览器配置文件、快速配置文件、内核和验证 +- 👤 **Profile Management API**: 用户认证、工作区、配置文件和文件夹管理 +- 🛡️ **类型安全**: 完整的 TypeScript 支持和全面的类型定义 +- 🔄 **错误处理**: 结构化的错误类和适当的 HTTP 状态码 +- 📡 **HTTP 客户端**: 基于 Axios,支持拦截器和自动重试 +- 🏗️ **NestJS 模块**: 标准的 NestJS 动态模块设计 + +## 安装 + +```bash +pnpm add @yikart/multilogin +``` + +## 使用方法 + +### 模块导入 + +```typescript +import { Module } from '@nestjs/common' +import { MultiloginConfig } from '../src/multilogin.config' +import { MultiloginModule } from '../src/multilogin.module' + +const config: MultiloginConfig = { + launcherBaseUrl: 'https://launcher.mlx.yt:45001', + profileBaseUrl: 'https://api.multilogin.com', + timeout: 30000, + accessToken: 'your-access-token' // 可选 +} + +@Module({ + imports: [ + MultiloginModule.forRoot(config) + ], +}) +export class AppModule {} +``` + +### 服务注入和使用 + +```typescript +import { Injectable } from '@nestjs/common' +import { MultiloginService } from '../src/multilogin.module' + +@Injectable() +export class SomeService { + constructor(private readonly multiloginService: MultiloginService) {} + + async startProfile() { + // 使用 launcher 客户端 + return await this.multiloginService.launcher.startBrowserProfile( + 'folder-id', + 'profile-id', + { automation_type: 'selenium', headless_mode: false } + ) + } + + async getUserWorkspaces() { + // 使用 profile 客户端 + return await this.multiloginService.profile.getUserWorkspaces() + } +} +``` + +### API 使用示例 + +#### Launcher API + +```typescript +@Injectable() +export class BrowserService { + constructor(private readonly multiloginService: MultiloginService) {} + + // 启动浏览器配置文件 + async startBrowserProfile(folderId: string, profileId: string) { + return await this.multiloginService.launcher.startBrowserProfile( + folderId, + profileId, + { automation_type: 'selenium', headless_mode: false } + ) + } + + // 创建快速配置文件 + async createQuickProfile() { + return await this.multiloginService.launcher.startQuickProfileV3({ + browser_type: 'mimic', + os_type: 'linux', + automation: 'selenium', + core_version: 124, + is_headless: false, + parameters: { + flags: { + audio_masking: 'mask', + fonts_masking: 'custom', + // ... 其他标志 + }, + fingerprint: { + navigator: { + hardware_concurrency: 8, + platform: 'Win32', + user_agent: 'Mozilla/5.0...', + os_cpu: '', + }, + // ... 其他指纹数据 + }, + }, + }) + } + + // 停止配置文件 + async stopProfile(profileId: string) { + await this.multiloginService.launcher.stopBrowserProfile(profileId) + } + + // 获取版本信息 + async getVersion() { + return await this.multiloginService.launcher.getVersion() + } +} +``` + +#### Profile Management API + +```typescript +@Injectable() +export class ProfileManagementService { + constructor(private readonly multiloginService: MultiloginService) {} + + // 用户登录 + async signIn(email: string, password: string) { + return await this.multiloginService.profile.signIn({ email, password }) + } + + // 获取配置文件列表 + async getProfiles(folderId?: string) { + return await this.multiloginService.profile.getProfiles(folderId) + } + + // 创建文件夹 + async createFolder(name: string, parentId?: string) { + return await this.multiloginService.profile.createFolder(name, parentId) + } + + // 获取用户工作区 + async getUserWorkspaces() { + return await this.multiloginService.profile.getUserWorkspaces() + } +} +``` + +### 错误处理 + +```typescript +import { MultiloginAuthenticationError, MultiloginError, MultiloginValidationError } from '../src/multilogin.exception' + +@Injectable() +export class ExampleService { + constructor(private readonly multiloginService: MultiloginService) {} + + async handleApiCall() { + try { + await this.multiloginService.launcher.startBrowserProfile('folder-id', 'profile-id') + } + catch (error) { + if (error instanceof MultiloginAuthenticationError) { + // 处理认证错误 + throw new TypeError(`认证失败: ${error.message}`) + } + else if (error instanceof MultiloginValidationError) { + // 处理验证错误 + throw new TypeError('验证错误') + } + else if (error instanceof MultiloginError) { + // 处理其他 API 错误 + throw new TypeError(`API 错误: ${error.message}`) + } + else { + // 处理未知错误 + throw new TypeError('未知错误') + } + } + } +} +``` + +## API 覆盖范围 + +### Launcher API ✅ + +- ✅ 启动/停止浏览器配置文件 +- ✅ 快速配置文件管理 (v2/v3) +- ✅ 浏览器内核管理 +- ✅ 配置文件状态监控 +- ✅ Cookie 导入/导出 +- ✅ 代理验证 +- ✅ QBP 转换为配置文件 + +### Profile Management API ✅ + +- ✅ 用户认证 +- ✅ 令牌管理 +- ✅ 工作区管理 +- ✅ 配置文件 CRUD 操作 +- ✅ 文件夹管理 + +### 不包含的功能 🚫 + +- ❌ 对象存储端点 +- ❌ 代理管理端点 + +## 类型定义 + +该模块提供了完整的 TypeScript 类型定义: + +- `BrowserType`, `OsType`, `AutomationType` +- `ProfileResponse`, `ProfileStatus`, `QuickProfileRequest` +- `BrowserFingerprint`, `MaskingFlags`, `ProxyConfig` +- `AuthResponse`, `UserProfile`, `Workspace` +- 以及更多... + +## 配置选项 + +```typescript +export interface MultiloginConfig { + launcherBaseUrl?: string // 默认: 'https://launcher.mlx.yt:45001' + profileBaseUrl?: string // 默认: 'https://api.multilogin.com' + timeout?: number // 默认: 30000 + accessToken?: string // 可选的访问令牌 +} +``` + +## 构建 + +运行 `nx build multilogin` 来构建库。 + +## 许可证 + +MIT diff --git a/project/aitoearn-monorepo/libs/multilogin/eslint.config.mjs b/project/aitoearn-monorepo/libs/multilogin/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/multilogin/package.json b/project/aitoearn-monorepo/libs/multilogin/package.json new file mode 100644 index 000000000..2a9e4ff9b --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/package.json @@ -0,0 +1,18 @@ +{ + "name": "@yikart/multilogin", + "type": "commonjs", + "version": "0.0.3", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "dependencies": { + "axios": "1.11.0", + "tslib": "^2.3.0" + } +} diff --git a/project/aitoearn-monorepo/libs/multilogin/project.json b/project/aitoearn-monorepo/libs/multilogin/project.json new file mode 100644 index 000000000..d7af929c5 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/project.json @@ -0,0 +1,42 @@ +{ + "name": "multilogin", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/multilogin/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/multilogin", + "tsConfig": "libs/multilogin/tsconfig.lib.json", + "packageJson": "libs/multilogin/package.json", + "main": "libs/multilogin/src/index.ts", + "assets": ["libs/multilogin/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/multilogin/src/index.ts b/project/aitoearn-monorepo/libs/multilogin/src/index.ts new file mode 100644 index 000000000..4b14cfe6b --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/src/index.ts @@ -0,0 +1,3 @@ +export * from './multilogin.client' +export * from './multilogin.exception' +export * from './multilogin.interface' diff --git a/project/aitoearn-monorepo/libs/multilogin/src/multilogin.client.ts b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.client.ts new file mode 100644 index 000000000..37d7ad7a6 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.client.ts @@ -0,0 +1,489 @@ +import { createHash } from 'node:crypto' +import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios' +import { + MultiloginError, + MultiloginRateLimitError, +} from './multilogin.exception' +import { + AllProfilesStatusResponse, + AllQuickProfilesStatusResponse, + AuthRefreshTokenRequest, + AuthResponse, + AuthRevokeTokenRequest, + AuthSignInRequest, + AutomationTokenResponse, + BrowserCoreListResponse, + ConvertQBPRequest, + ConvertQBPResponse, + CookieExportResponse, + CookieImportRequest, + CookieImportResponse, + CreateFolderRequest, + CreateFolderResponse, + CreateProfileRequest, + CreateProfileResponse, + FolderListItem, + FoldersResponse, + LoadBrowserCoreResponse, + LoadedBrowserCoresResponse, + MultiloginClientConfig, + ProfileMetasRequest, + ProfileMetasResponse, + ProfileRemoveRequest, + ProfileResponse, + ProfileSearchRequest, + ProfileSearchResponse, + ProfileStatus, + ProfileSummaryResponse, + QuickProfileRequest, + RemoveFoldersRequest, + TokenListResponse, + UnlockProfilesRequest, + UnlockProfilesResponse, + UpdateFolderRequest, + UpdateProfileRequest, + ValidateProxyRequest, + ValidateProxyResponse, + VersionResponse, + WorkspacesResponse, +} from './multilogin.interface' + +export class MultiloginClient { + private httpClient: AxiosInstance + private launcherClient: AxiosInstance + private token?: string + private email: string + private password: string + private isRefreshing = false + private refreshPromise?: Promise + private onTokenRefresh?: (token: string) => void | Promise + private useAutomationTokenRefresh: boolean + + constructor(config: MultiloginClientConfig) { + this.email = config.email + this.password = config.password + this.token = config.token + this.onTokenRefresh = config.onTokenRefresh + this.useAutomationTokenRefresh = config.useAutomationTokenRefresh ?? true + + // 创建 Profile API 的 HTTP 客户端 + this.httpClient = this.createHttpClient( + config.profileBaseUrl || 'https://api.multilogin.com', + config.timeout, + ) + + // 创建 Launcher API 的 HTTP 客户端 + this.launcherClient = this.createHttpClient( + config.launcherBaseUrl || 'https://launcher.mlx.yt:45001', + config.timeout, + ) + } + + private createHttpClient(baseUrl: string, timeout = 60000): AxiosInstance { + const client = axios.create({ + baseURL: baseUrl, + timeout, + headers: { + 'Content-Type': 'application/json', + }, + }) + + // 添加请求拦截器,自动添加认证头 + client.interceptors.request.use(async (config) => { + if (!this.token) { + await this.refreshTokenIfNeeded() + } + + if (this.token && config.headers) { + config.headers.Authorization = `Bearer ${this.token}` + } + return config + }) + + client.interceptors.response.use( + response => response, + async (error) => { + if (error.response?.status === 401 && error.config) { + await this.refreshTokenIfNeeded() + // 重试原始请求 + const originalRequest = error.config + if (this.token && originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${this.token}` + } + return client.request(originalRequest) + } + throw this.handleError(error) + }, + ) + + return client + } + + private handleError(error: AxiosError): MultiloginError { + if (!error.response) { + return error + } + + const { status, data } = error.response + const message = (data as Record)?.['msg'] as string || error.message + + switch (status) { + case 429: + return new MultiloginRateLimitError(message) + default: + return new MultiloginError(message, status, data) + } + } + + /** + * 刷新自动化令牌 + * 根据配置决定是否执行完整的刷新流程 + */ + async refreshAutomationToken(): Promise { + if (!this.useAutomationTokenRefresh) { + const token = await this.signIn({ + email: this.email, + password: this.password, + }) + await this.setToken(token.data.token) + return token.data.token + } + + await this.signIn({ + email: this.email, + password: this.password, + }) + + const automationToken = await this.getAutomationToken('no_exp') + const token = automationToken.data.token + await this.setToken(token) + return token + } + + /** + * 如果需要则刷新 token(防止并发刷新) + */ + private async refreshTokenIfNeeded(): Promise { + if (this.isRefreshing) { + // 如果正在刷新,等待刷新完成 + if (this.refreshPromise) { + await this.refreshPromise + } + return + } + + this.isRefreshing = true + this.refreshPromise = this.refreshAutomationToken().finally(() => { + this.isRefreshing = false + this.refreshPromise = undefined + }) + + await this.refreshPromise + } + + /** + * 设置token并触发hook + */ + private async setToken(newToken: string): Promise { + this.token = newToken + if (this.onTokenRefresh) { + await this.onTokenRefresh(newToken) + } + } + + /** + * 获取当前token + */ + public getToken(): string | undefined { + return this.token + } + + // ==================== Profile API Methods ==================== + + async signIn(request: AuthSignInRequest): Promise { + const hashedPassword = createHash('md5').update(request.password).digest('hex') + const requestWithHashedPassword = { + ...request, + password: hashedPassword, + } + + const response: AxiosResponse = await axios.post( + `${this.httpClient.defaults.baseURL}/user/signin`, + requestWithHashedPassword, + { + timeout: this.httpClient.defaults.timeout, + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + this.token = response.data.data.token + return response.data + } + + async refreshToken(request: AuthRefreshTokenRequest): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/user/refresh_token', + request, + ) + this.token = response.data.data.token + return response.data + } + + async revokeToken(request: AuthRevokeTokenRequest): Promise { + await this.httpClient.post('/user/revoke_tokens', request) + } + + async changePassword(currentPassword: string, newPassword: string): Promise { + // 对密码进行 MD5 哈希处理 + const hashedCurrentPassword = createHash('md5').update(currentPassword).digest('hex') + const hashedNewPassword = createHash('md5').update(newPassword).digest('hex') + + await this.httpClient.post('/user/change_password', { + password: hashedCurrentPassword, + new_password: hashedNewPassword, + }) + } + + async getUserWorkspaces(): Promise { + const response: AxiosResponse = await this.httpClient.get('/user/workspaces') + return response.data + } + + async getTokenList(): Promise { + const response: AxiosResponse = await this.httpClient.get('/user/tokens_list') + return response.data + } + + async getAutomationToken(expirationPeriod?: string): Promise { + const params = expirationPeriod ? { expiration_period: expirationPeriod } : {} + const response: AxiosResponse = await this.httpClient.get( + '/workspace/automation_token', + { params }, + ) + return response.data + } + + async searchProfiles(request: ProfileSearchRequest): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/profile/search', + request, + ) + return response.data + } + + async createProfile(profile: CreateProfileRequest): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/profile/create', + profile, + ) + return response.data + } + + async updateProfile(profileId: string, profile: UpdateProfileRequest): Promise { + const request = { + profile_id: profileId, + ...profile, + } + await this.httpClient.post('/profile/update', request) + } + + async deleteProfile(profileId: string, permanently = false): Promise { + const request: ProfileRemoveRequest = { + ids: [profileId], + permanently, + } + await this.httpClient.post('/profile/remove', request) + } + + async getFolders(): Promise { + const response: AxiosResponse = await this.httpClient.get( + '/workspace/folders', + ) + return response.data.data.folders + } + + async createFolder(name: string, comment?: string): Promise { + const request: CreateFolderRequest = { name, comment } + const response: AxiosResponse = await this.httpClient.post( + '/workspace/folder_create', + request, + ) + return response.data + } + + async updateFolder(folderId: string, name: string, comment?: string): Promise { + const request: UpdateFolderRequest = { + folder_id: folderId, + name, + comment, + } + await this.httpClient.post('/workspace/folder_update', request) + } + + async deleteFolders(folderIds: string[]): Promise { + const request: RemoveFoldersRequest = { ids: folderIds } + await this.httpClient.post('/workspace/folders_remove', request) + } + + async deleteFolder(folderId: string): Promise { + await this.deleteFolders([folderId]) + } + + // ==================== Launcher API Methods ==================== + + async startBrowserProfile( + folderId: string, + profileId: string, + options?: { automation_type?: string, headless_mode?: boolean }, + ): Promise { + const params = new URLSearchParams() + if (options?.automation_type) { + params.append('automation_type', options.automation_type) + } + if (options?.headless_mode !== undefined) { + params.append('headless_mode', options.headless_mode.toString()) + } + + const response: AxiosResponse = await this.launcherClient.get( + `/api/v2/profile/f/${folderId}/p/${profileId}/start${params.toString() ? `?${params.toString()}` : ''}`, + ) + return response.data + } + + async startQuickProfile(request: QuickProfileRequest): Promise { + const response: AxiosResponse = await this.launcherClient.post( + '/api/v3/profile/quick', + request, + ) + return response.data + } + + async stopBrowserProfile(profileId: string): Promise { + await this.launcherClient.get(`/api/v1/profile/stop/p/${profileId}`) + } + + async stopAllProfiles(type: 'all' | 'regular' | 'quick' = 'all'): Promise { + await this.launcherClient.get(`/api/v1/profile/stop_all?type=${type}`) + } + + async getVersion(): Promise { + const response: AxiosResponse = await this.launcherClient.get('/api/v1/version') + return response.data + } + + async getProfileStatus(profileId: string): Promise { + const response: AxiosResponse = await this.launcherClient.get( + `/api/v1/profile/status/p/${profileId}`, + ) + return response.data + } + + async getAllProfilesStatus(): Promise { + const response: AxiosResponse = await this.launcherClient.get( + '/api/v1/profile/statuses', + ) + return response.data + } + + async getAllQuickProfilesStatus(): Promise { + const response: AxiosResponse = await this.launcherClient.get( + '/api/v1/profile/quick/statuses', + ) + return response.data + } + + async getLoadedBrowserCores(): Promise { + const response: AxiosResponse = await this.launcherClient.get( + '/api/v1/loaded_browser_cores', + ) + return response.data + } + + async getBrowserCoreList(): Promise { + const response: AxiosResponse = await this.launcherClient.get('/bcs/core/list') + return response.data + } + + async loadBrowserCore(coreType: string, version: string): Promise { + const response: AxiosResponse = await this.launcherClient.get( + '/api/v1/load_browser_core', + { + params: { + browser_type: coreType, + version, + }, + }, + ) + return response.data + } + + async deleteBrowserCore(coreType: string, version: string): Promise { + const response: AxiosResponse = await this.launcherClient.delete( + '/api/v1/delete_browser_core', + { + params: { + browser_type: coreType, + version, + }, + }, + ) + return response.data + } + + async validateProxy(request: ValidateProxyRequest): Promise { + const response: AxiosResponse = await this.launcherClient.post( + '/api/v1/proxy/validate', + request, + ) + return response.data + } + + async importCookies(request: CookieImportRequest): Promise { + const response: AxiosResponse = await this.launcherClient.post( + '/api/v1/cookie_import', + request, + ) + return response.data + } + + async exportCookies(profileId: string): Promise { + const response: AxiosResponse = await this.launcherClient.post( + '/api/v1/cookie_export', + { profile_id: profileId }, + ) + return response.data + } + + async convertQBPToProfile(request: ConvertQBPRequest): Promise { + const response: AxiosResponse = await this.launcherClient.post( + '/api/v1/profile/quick/save', + request, + ) + return response.data + } + + async unlockProfiles(request?: UnlockProfilesRequest): Promise { + const response: AxiosResponse = await this.httpClient.request({ + method: 'GET', + url: '/bpds/profile/unlock_profiles', + data: request, + }) + return response.data + } + + async getProfileMetas(request: ProfileMetasRequest): Promise { + const response: AxiosResponse = await this.httpClient.post( + '/profile/metas', + request, + ) + return response.data + } + + async getProfileSummary(metaId: string): Promise { + const response: AxiosResponse = await this.httpClient.get( + `/profile/summary?meta_id=${metaId}`, + ) + return response.data + } +} diff --git a/project/aitoearn-monorepo/libs/multilogin/src/multilogin.exception.ts b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.exception.ts new file mode 100644 index 000000000..31d952b68 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.exception.ts @@ -0,0 +1,18 @@ +export class MultiloginError extends Error { + public readonly statusCode?: number + public response?: unknown + + constructor(message: string, statusCode?: number, response?: unknown) { + super(message) + this.name = 'MultiloginError' + this.statusCode = statusCode + this.response = response + } +} + +export class MultiloginRateLimitError extends MultiloginError { + constructor(message = 'Rate limit exceeded') { + super(message, 429) + this.name = 'MultiloginRateLimitError' + } +} diff --git a/project/aitoearn-monorepo/libs/multilogin/src/multilogin.interface.ts b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.interface.ts new file mode 100644 index 000000000..14a32ed21 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/src/multilogin.interface.ts @@ -0,0 +1,598 @@ +export type BrowserType = 'mimic' | 'stealthfox' +export type OsType = 'windows' | 'macos' | 'linux' | 'android' +export type AutomationType = 'selenium' | 'puppeteer' | 'playwright' +export type ProxyType = 'http' | 'https' | 'socks4' | 'socks5' + +// ==================== 通用响应接口 ==================== + +/** + * Multilogin API 通用状态结构 + */ +export interface MultiloginStatus { + error_code: string | number + http_code: number + message: string +} + +/** + * Multilogin API 通用响应基础接口 + */ +export interface MultiloginBaseResponse { + status: MultiloginStatus +} + +/** + * 带数据的 Multilogin API 响应接口 + */ +export interface MultiloginDataResponse extends MultiloginBaseResponse { + data: T +} + +/** + * 仅包含状态的 Multilogin API 响应接口(用于无返回数据的操作) + */ +export interface MultiloginStatusResponse extends MultiloginBaseResponse {} + +// ==================== 业务类型定义 ==================== + +export interface ProxyConfig { + host: string + type: ProxyType + port: number + username?: string + password?: string + save_traffic?: boolean +} + +export interface NavigatorFingerprint { + hardware_concurrency: number + platform: string + user_agent: string + os_cpu: string + max_touch_points?: number +} + +export interface LocalizationFingerprint { + languages: string + locale: string + accept_languages: string +} + +export interface TimezoneFingerprint { + zone: string +} + +export interface GraphicFingerprint { + renderer: string + vendor: string +} + +export interface WebRTCFingerprint { + public_ip: string +} + +export interface MediaDevicesFingerprint { + audio_inputs: number + audio_outputs: number + video_inputs: number +} + +export interface ScreenFingerprint { + height: number + pixel_ratio: number + width: number +} + +export interface GeolocationFingerprint { + accuracy: number + altitude: number + latitude: number + longitude: number +} + +export interface CmdParam { + flag: string + value: boolean | string +} + +export interface CmdParams { + params: CmdParam[] +} + +export interface BrowserFingerprint { + navigator: NavigatorFingerprint + localization: LocalizationFingerprint + timezone: TimezoneFingerprint + graphic: GraphicFingerprint + webrtc: WebRTCFingerprint + media_devices: MediaDevicesFingerprint + screen: ScreenFingerprint + geolocation: GeolocationFingerprint + ports: number[] + fonts: string[] + cmd_params: CmdParams +} + +export interface MaskingFlags { + audio_masking?: 'mask' | 'natural' + fonts_masking?: 'natural' | 'custom' | 'mask' + geolocation_masking?: 'mask' | 'custom' + geolocation_popup?: 'prompt' | 'allow' | 'block' + graphics_masking?: 'natural' | 'custom' | 'mask' + graphics_noise?: 'mask' | 'natural' + localization_masking?: 'natural' | 'custom' | 'mask' + media_devices_masking?: 'natural' | 'custom' | 'mask' + navigator_masking?: 'natural' | 'custom' | 'mask' + ports_masking?: 'mask' | 'natural' + proxy_masking?: 'custom' | 'disabled' + screen_masking?: 'natural' | 'custom' | 'mask' + timezone_masking?: 'natural' | 'custom' | 'mask' + webrtc_masking?: 'natural' | 'custom' | 'mask' | 'disabled' + canvas_noise?: 'mask' | 'natural' | 'disabled' + startup_behavior?: 'recover' | 'custom' + quic_mode?: 'natural' | 'disabled' +} + +export interface StartProfileOptions { + automation_type?: AutomationType + headless_mode?: boolean + custom_start_urls?: string[] +} + +export interface QuickProfileParameters { + flags?: MaskingFlags + fingerprint?: BrowserFingerprint + custom_start_urls?: string[] + proxy?: ProxyConfig +} + +export interface StorageParameters { + is_local?: boolean + save_service_worker?: boolean +} + +export interface ProfileParameters { + flags?: MaskingFlags + storage?: StorageParameters + fingerprint?: BrowserFingerprint + proxy?: ProxyConfig + custom_start_urls?: string[] +} + +export interface StartProfileRequest { + browser_type: BrowserType + os_type: OsType + script_file?: string + automation: AutomationType + core_version?: number + core_minor_version?: number + is_headless: boolean + parameters?: ProfileParameters +} + +export interface QuickProfileRequest { + browser_type: BrowserType + os_type: OsType + automation: AutomationType + core_version: number + is_headless: boolean + parameters: QuickProfileParameters +} + +export interface ProfileStatus extends MultiloginDataResponse<{ + browser_type: string + core_version: number + folder_id: string + in_use_by: string + is_quick: boolean + last_launched_at: string + last_launched_by: string + last_launched_on: string + message: string + name: string + profile_id: string + status: string + timestamp: number + workspace_id: string +}> {} + +export interface ProfileStatusItem { + browser_type: string + core_version: number + folder_id: string + in_use_by: string + is_quick: boolean + last_launched_at: string + last_launched_by: string + last_launched_on: string + message: string + name: string + profile_id: string + status: string + timestamp: number + workspace_id: string +} + +export interface AllProfilesStatusResponse extends MultiloginDataResponse<{ + active_counter: { + cloud: number + local: number + quick: number + } + states: Record +}> {} + +export interface QuickProfileStatusItem { + browser_type: string + is_quick: boolean + message: string + name: string + status: string + timestamp: number +} + +export interface AllQuickProfilesStatusResponse extends MultiloginDataResponse<{ + active_counter: number + states: Record +}> {} + +export interface ProfileResponse extends MultiloginDataResponse<{ + browser_type: string + core_version: number + id: string + is_quick: boolean + port: string +}> {} + +export interface VersionResponse extends MultiloginDataResponse<{ + env: string + version: string +}> {} + +export interface BrowserCore { + name: string + version: string + type: BrowserType + status: 'downloaded' | 'available' | 'loading' +} + +export interface BrowserCoreVersion { + full_versions: string[] + major_version: number +} + +export interface BrowserCoreInfo { + browser_type: string + versions: BrowserCoreVersion[] +} + +export interface BrowserCoreListResponse extends MultiloginDataResponse<{ + core_versions: BrowserCoreInfo[] +}> {} + +export interface LoadedBrowserCore { + is_latest: boolean + latest_version: string + type: string + versions: string[] +} + +export interface LoadedBrowserCoresResponse extends MultiloginDataResponse {} + +export interface LoadBrowserCoreResponse extends MultiloginStatusResponse {} + +export interface CookieImportResponse extends MultiloginStatusResponse {} + +export interface ConvertQBPResponse extends MultiloginStatusResponse {} + +export interface ValidateProxyRequest { + proxy: ProxyConfig +} + +export interface ValidateProxyResponse extends MultiloginDataResponse<{ + accuracy: number + altitude: number + country_code: string + ip: string + latitude: number + longitude: number + timezone: string +}> {} + +export interface CookieData { + name: string + value: string + domain: string + path?: string + expires?: number + httpOnly?: boolean + secure?: boolean + sameSite?: 'Strict' | 'Lax' | 'None' +} + +export interface CookieImportRequest { + profile_id: string + cookies: CookieData[] +} + +export interface CookieExportResponse extends MultiloginDataResponse<{ + cookies: string + profile_id: string + timestamp: number +}> {} + +export interface ConvertQBPRequest { + quick_profile_id: string + folder_id: string + name: string +} + +export interface AuthSignInRequest { + email: string + password: string +} + +export interface AuthResponse extends MultiloginDataResponse<{ + refresh_token: string + token: string +}> {} + +export interface SignInResponse extends MultiloginDataResponse<{ + token: string + refresh_token: string + expires_in: number +}> {} + +export interface RefreshTokenResponse extends MultiloginDataResponse<{ + token: string + refresh_token: string + expires_in: number +}> {} + +export interface RevokeTokenResponse extends MultiloginStatusResponse {} + +export interface ChangePasswordResponse extends MultiloginStatusResponse {} + +export interface AuthRefreshTokenRequest { + email: string + refresh_token: string + workspace_id: string +} + +export interface AuthRevokeTokenRequest { + token?: string + is_automation?: boolean +} + +export interface Workspace { + workspace_id: string + name: string + role: string +} + +export interface WorkspacesResponse extends MultiloginDataResponse<{ + total_count: number + workspaces: Workspace[] +}> {} + +export interface UserWorkspacesResponse extends MultiloginDataResponse<{ + workspaces: Workspace[] +}> {} + +export interface UserProfile { + id: string + email: string + name: string + workspaces: Workspace[] +} + +export interface ProfileListItem { + id: string + name: string + folder_id: string + browser_type: BrowserType + os_type: OsType + core_version: number + notes?: string + parameters?: ProfileParameters + created_at: string + updated_at: string + is_removed?: boolean + storage_type?: string + abp_status?: boolean + created_by?: string + in_use_by?: string + is_local?: boolean + last_launched_by?: string + last_launched_on?: string + last_launched_at?: string + locked_by?: string + password_protected?: boolean + password_restricted?: boolean +} + +export interface FolderListItem { + id: string + name: string + parent_id?: string + created_at: string + updated_at: string +} + +export interface TokenInfo { + id: string + name: string + permissions: string[] + created_at: string +} + +export interface TokenListResponse extends MultiloginDataResponse<{ + tokens: Array<{ + token: string + }> +}> {} + +export interface AutomationTokenResponse extends MultiloginDataResponse<{ + token: string +}> {} + +export interface CreateProfileRequest { + name: string + browser_type: BrowserType + folder_id: string + os_type: OsType + core_version?: number + core_minor_version?: number + times?: number + auto_update_core?: boolean + tags?: string[] + notes?: string + parameters?: ProfileParameters +} + +export interface CreateProfileResponse extends MultiloginDataResponse<{ + ids: string[] +}> {} + +export interface UpdateProfileRequest { + name?: string + auto_update_core?: boolean + core_version?: number + core_minor_version?: number + tags?: string[] + notes?: string + parameters?: ProfileParameters +} + +export interface ProfileSearchRequest { + is_removed?: boolean + core_version?: number + limit?: number + offset?: number + search_text?: string + folder_id?: string + storage_type?: string + order_by?: string + sort?: 'asc' | 'desc' + date_from?: string + date_to?: string + created_date_from?: string + created_date_to?: string + updated_date_from?: string + updated_date_to?: string + browser_type?: BrowserType + tags?: string[] + password_protected?: boolean + os_type?: OsType +} + +export interface ProfileSearchResponse extends MultiloginDataResponse<{ + profiles: ProfileListItem[] + total_count: number +}> {} + +export interface ProfileRemoveRequest { + ids: string[] + permanently: boolean +} + +export interface CreateFolderRequest { + name: string + comment?: string +} + +export interface CreateFolderResponse extends MultiloginDataResponse<{ + id: string +}> {} + +export interface UpdateFolderRequest { + folder_id: string + name: string + comment?: string +} + +export interface RemoveFoldersRequest { + ids: string[] +} + +export interface FoldersResponse extends MultiloginDataResponse<{ + folders: FolderListItem[] +}> {} + +export interface UnlockProfilesRequest { + ids?: string[] +} + +export interface UnlockProfilesResponse extends MultiloginStatusResponse {} + +export interface ProfileMetasRequest { + ids: string[] +} + +export interface ProfileMetaItem { + id: string + is_auto_update: boolean + name: string + notes: string + parameters: { + fingerprint: Record + flags: MaskingFlags + storage: StorageParameters + } + browser_type: BrowserType + core_version: number + os_type: OsType + created_at: string + created_by: string + in_use_by: string + last_launched_at: string + last_launched_by: string + last_launched_on: string + last_update_at: string + last_updated_by: string + removed_at: string + removed_by: string + status: string + folder_id: string + workspace_id: string +} + +export interface ProfileMetasResponse extends MultiloginDataResponse<{ + profiles: ProfileMetaItem[] +}> {} + +export interface ProfileSummaryRequest { + meta_id: string +} + +export interface ProfileSummaryData { + fonts: string[] + geolocation: GeolocationFingerprint + graphic: { + device_id: string + renderer: string + vendor: string + vendor_id: string + } + localization: LocalizationFingerprint + masking_options: Record + media_devices: MediaDevicesFingerprint + navigator: NavigatorFingerprint + ports: number[] + screen: ScreenFingerprint + timezone: TimezoneFingerprint + webrtc: WebRTCFingerprint +} + +export interface ProfileSummaryResponse extends MultiloginDataResponse {} + +export interface MultiloginClientConfig { + profileBaseUrl?: string + launcherBaseUrl?: string + timeout?: number + email: string + password: string + token?: string + onTokenRefresh?: (token: string) => void | Promise + useAutomationTokenRefresh?: boolean +} diff --git a/project/aitoearn-monorepo/libs/multilogin/tsconfig.json b/project/aitoearn-monorepo/libs/multilogin/tsconfig.json new file mode 100644 index 000000000..17b188fbf --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "importHelpers": true, + "forceConsistentCasingInFileNames": true + }, + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "files": [], + "include": [] +} diff --git a/project/aitoearn-monorepo/libs/multilogin/tsconfig.lib.json b/project/aitoearn-monorepo/libs/multilogin/tsconfig.lib.json new file mode 100644 index 000000000..b1ae6db58 --- /dev/null +++ b/project/aitoearn-monorepo/libs/multilogin/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "target": "es2021", + "types": ["node"], + "strictBindCallApply": true, + "strictNullChecks": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "declaration": true, + "outDir": "../../dist/out-tsc", + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/project/aitoearn-monorepo/libs/one-signal/README.md b/project/aitoearn-monorepo/libs/one-signal/README.md new file mode 100644 index 000000000..9c3179890 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/README.md @@ -0,0 +1,7 @@ +# one-signal + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build one-signal` to build the library. diff --git a/project/aitoearn-monorepo/libs/one-signal/eslint.config.mjs b/project/aitoearn-monorepo/libs/one-signal/eslint.config.mjs new file mode 100644 index 000000000..29c3abcc9 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/eslint.config.mjs @@ -0,0 +1,24 @@ +import baseConfig from '../../eslint.config.mjs' + +export default baseConfig.append( + { + files: [ + '**/*.json', + ], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: [ + '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}', + ], + checkObsoleteDependencies: false, + checkVersionMismatches: false, + }, + ], + }, + languageOptions: { + parser: (await import('jsonc-eslint-parser')), + }, + }, +) diff --git a/project/aitoearn-monorepo/libs/one-signal/package.json b/project/aitoearn-monorepo/libs/one-signal/package.json new file mode 100644 index 000000000..488648032 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/package.json @@ -0,0 +1,24 @@ +{ + "name": "@yikart/one-signal", + "type": "commonjs", + "version": "0.0.1", + "repository": { + "type": "git", + "url": "https://github.com/yikart/aitoearn-monorepo.git" + }, + "main": "./src/index.js", + "types": "./src/index.d.ts", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@yikart/common": "*", + "tslib": "^2.3.0", + "zod": "^4.0.0" + }, + "dependencies": { + "@onesignal/node-onesignal": "5.3.1-beta1", + "rxjs": "^7.8.0" + } +} diff --git a/project/aitoearn-monorepo/libs/one-signal/project.json b/project/aitoearn-monorepo/libs/one-signal/project.json new file mode 100644 index 000000000..d0c4c9283 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/project.json @@ -0,0 +1,42 @@ +{ + "name": "one-signal", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/one-signal/src", + "projectType": "library", + "release": { + "version": { + "manifestRootsToUpdate": ["dist/{projectRoot}"], + "currentVersionResolver": "git-tag", + "fallbackCurrentVersionResolver": "disk" + } + }, + "tags": [], + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/one-signal", + "tsConfig": "libs/one-signal/tsconfig.lib.json", + "packageJson": "libs/one-signal/package.json", + "main": "libs/one-signal/src/index.ts", + "assets": ["libs/one-signal/*.md"] + } + }, + "nx-release-publish": { + "dependsOn": ["build"], + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ], + "options": { + "fix": true + } + } + } +} diff --git a/project/aitoearn-monorepo/libs/one-signal/src/index.ts b/project/aitoearn-monorepo/libs/one-signal/src/index.ts new file mode 100644 index 000000000..703cdd4a1 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/src/index.ts @@ -0,0 +1,4 @@ +export * from './one-signal.config' +export * from './one-signal.interface' +export * from './one-signal.module' +export * from './one-signal.service' diff --git a/project/aitoearn-monorepo/libs/one-signal/src/one-signal.config.ts b/project/aitoearn-monorepo/libs/one-signal/src/one-signal.config.ts new file mode 100644 index 000000000..fd6a90058 --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/src/one-signal.config.ts @@ -0,0 +1,10 @@ +import { createZodDto } from '@yikart/common' +import z from 'zod' + +export const oneSignalConfigSchema = z.object({ + organizationApiKey: z.string().optional(), + restApiKey: z.string(), + appId: z.string(), +}) + +export class OneSignalConfig extends createZodDto(oneSignalConfigSchema) {} diff --git a/project/aitoearn-monorepo/libs/one-signal/src/one-signal.interface.ts b/project/aitoearn-monorepo/libs/one-signal/src/one-signal.interface.ts new file mode 100644 index 000000000..c7d7c90db --- /dev/null +++ b/project/aitoearn-monorepo/libs/one-signal/src/one-signal.interface.ts @@ -0,0 +1,317 @@ +import { Button, LanguageStringMap, NotificationTargetChannelEnum, WebButton } from '@onesignal/node-onesignal' + +export type LangMap = Partial> + +export interface BaseNotificationWithoutTemplate { + /** + * Your OneSignal App ID in UUID v4 format. See [Keys & IDs](https://documentation.onesignal.com/docs/keys-and-ids). + */ + app_id: string + /** + * The main message body with [language-specific values](https://documentation.onesignal.com/docs/multi-language-messaging#supported-languages). Supports [Message Personalization](https://documentation.onesignal.com/docs/message-personalization). + */ + contents: L + /** + * The message title with [language-specific values](https://documentation.onesignal.com/docs/multi-language-messaging#supported-languages). Required for Huawei and Web Push. If not set for Web Push, it defaults to your 'Site Name'. Not required if using template_id or content_available. Supports [Message Personalization](https://documentation.onesignal.com/docs/message-personalization) and must include the same languages as contents to ensure localization consistency. + */ + headings?: L + /** + * iOS only. The subtitle with [language-specific values](https://documentation.onesignal.com/docs/multi-language-messaging#supported-languages). Supports [Message Personalization](https://documentation.onesignal.com/docs/message-personalization) and must include the same languages as contents to ensure localization consistency. + */ + subtitle?: L + /** + * An internal name you set to help organize and track messages. Not shown to recipients. Maximum 128 characters. + */ + name?: string + /** + * The targeted delivery channel. Required when using include_aliases. Accepts push, email, or sms. + */ + target_channel?: NotificationTargetChannelEnum + /** + * The local name or URL of the media attachment to include in your notification. Users can expand the notification to view images, videos, or other supported attachments. See [Images & Rich Media](https://documentation.onesignal.com/docs/rich-media). + */ + ios_attachments?: { + /** + * The URL of the media to display in the notification. Example: https://avatars.githubusercontent.com/u/11823027?s=200&v=4 + */ + id: string + } + /** + * The local name or URL of the image to include in your Google Android notification. Users can expand the notification to view the images. See [Images & Rich Media](https://documentation.onesignal.com/docs/rich-media). + */ + big_picture?: string + /** + * The local name or URL of the image to include in your Huawei Android notification. Users can expand the notification to view the images. See Images & Rich Media. + */ + huawei_big_picture?: string + /** + * The local name or URL of the image to include in your Amazon Android notification. Users can expand the notification to view the images. See Images & Rich Media. + */ + adm_big_picture?: string + /** + * The URL of the image to include in your Chrome notification. Users can expand the notification to view the images. Supported on Chrome for Windows and Android. macOS does not support this parameter and instead expands the chrome_web_icon. See Images & Rich Media. + */ + chrome_web_image?: string + /** + * The local name of the small icon to display in the Google Android notification. See Notification icons. + */ + small_icon?: string + /** + * The local name of the small icon to display in the Huawei Android notification. See Notification icons. + */ + huawei_small_icon?: string + /** + * The local name of the small icon to display in the Amazon Android notification. See Notification icons. + */ + adm_small_icon?: string + /** + * The local name or URL of the large icon to display in the Google Android notification. See Notification icons. + */ + large_icon?: string + /** + * The local name or URL of the large icon to display in the Huawei Android notification. See Notification icons. + */ + huawei_large_icon?: string + /** + * The local name or URL of the large icon to display in the Amazon Android notification. See Notification icons. + */ + adm_large_icon?: string + /** + * The URL of the icon to display in the Chrome web notification. Defaults to the resource set in the OneSignal dashboard. See Notification icons. + */ + chrome_web_icon?: string + /** + * The URL of the icon to display in the Firefox web notification. Defaults to the resource set in the OneSignal dashboard. See Notification icons. + */ + firefox_icon?: string + /** + * The URL of the icon to display in the Android notification tray for Chrome web notifications. Defaults to the Chrome icon. See Push. + */ + chrome_web_badge?: string + /** + * The UUID of the Android notification channel category created within your OneSignal app. + */ + android_channel_id?: string + /** + * The UUID of the Android notification channel category created within your Android app. + */ + existing_android_channel_id?: string + /** + * The UUID of the Android notification channel category created within your OneSignal app. + */ + huawei_channel_id?: string + /** + * The UUID of the Android notification channel category created within your Huawei app. + */ + huawei_existing_channel_id?: string + /** + * The category you set for notifications sent to Huawei devices. The category chosen must align with an approved [self-classification application](https://developer.huawei.com/consumer/cn/doc/HMSCore-Guides/message-classification-0000001149358835#section1653845862216). Subject to daily send limitations ranging from 2 to 5, depending on the specific [third-level classifications](https://developer.huawei.com/consumer/cn/doc/development/HMSCore-Guides/message-restriction-description-0000001361648361#section199311418515) the message falls under. + */ + huawei_category?: 'MARKETING' | 'IM' | 'VOIP' | 'SUBSCRIPTION' | 'TRAVEL' | 'HEALTH' | 'WORK' | 'ACCOUNT' | 'EXPRESS' | 'FINANCE' | 'DEVICE_REMINDER' | 'MAIL' + /** + * The type of notification being sent to Huawei devices. Options: message - (default) For displayable notifications to the user. Notification will be shown even if the app is force quit. If the device is offline it will display the notification when it connects to the internet within the ttl timeframe (usually 3 days). Does not support Confirmed Delivery, Huawei requires using their dashboard to track this. data - used for notifications containing data payloads you intend to process in the background. If the app is force quit, HMS Core will not start the app to process the notification. Supports [Confirmed Delivery](https://documentation.onesignal.com/docs/confirmed-delivery#huawei). + */ + huawei_msg_type?: 'message' | 'data' + /** + * Define a tag for associating messages in a batch delivery, facilitating precise monitoring and analysis of delivery stats. This tag is returned to your server when Huawei's Push Kit sends a message receipt. You can set this parameter to track your push campaigns' performance and optimize your messaging strategy. + */ + huawei_bi_tag?: string + /** + * Set the priority based on the urgency of the message. 10 - High priority. 5 - Normal priority. Recommended and default value is 10. APNs and FCM use this parameter to determine how quickly a notification is delivered and processed, particularly in power-saving modes. If sending data/background notifications, 5 (Normal priority) is recommended. For details, see [APNs apns-priority](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns) and [FCM priority](https://firebase.google.com/docs/cloud-messaging/android/message-priority). + */ + priority?: 10 | 5 + /** + * The priority and delivery timing of iOS notifications based on their importance and the urgency with which they should interrupt the user. See [iOS Focus modes and interruption levels](https://documentation.onesignal.com/docs/ios-focus-modes-and-interruption-levels). + * + * Available options: active, passive, time_sensitive, critical + */ + ios_interruption_level?: 'active' | 'passive' | 'time_sensitive' | 'critical' + /** + * The local name of the custom sound file to play when the notification is received instead of the default sound. See [Notification sounds](https://documentation.onesignal.com/docs/notification-sounds). + */ + ios_sound?: string + /** + * Set or increment the badge count on iOS devices. Use with ios_badgeCount. See [Badges](https://documentation.onesignal.com/docs/badges). + * + * Available options: None, SetTo, Increase + */ + ios_badgeType?: 'None' | 'SetTo' | 'Increase' + /** + * Use with ios_badgeType to determine the numerical change to your app's badge count. See [Badges](https://documentation.onesignal.com/docs/badges). + */ + ios_badgeCount?: number + /** + * The ARGB Hex formatted color of the Android small icon background. For Android 8+ use Android notification channel category and android_channel_id. + */ + android_accent_color?: string + /** + * The ARGB Hex formatted color of the Huawei small icon background. For Android 8+ use Android notification channel category and huawei_channel_id. + */ + huawei_accent_color?: string + /** + * The httpsURL that opens in the browser when a user interacts with the notification. See [URLs, Links and Deep Links](https://documentation.onesignal.com/docs/links). Supports Message Personalization. + */ + url?: string + /** + * Similar to the url parameter but exclusively targets mobile platforms like iOS, Android. Accepts values other than https but must use your-app-scheme:// protocol. + */ + app_url?: string + /** + * Use with app_url if your app and website need different URLs. Accepts URLs with protocol https:// + */ + web_url?: string + /** + * Direct the notification to a specific user experience within your app, such as an App Clip, or target a particular window in applications that use multiple scenes. See [Apple's documentation](https://developer.apple.com/documentation/foundation/nsuseractivity/3238062-targetcontentidentifier). + */ + target_content_identifier?: string + /** + * Add a maximum of 3 Action Buttons to Android and iOS push notifications. See [Action Buttons](https://documentation.onesignal.com/docs/action-buttons). + */ + buttons?: Array + ), + ( + + ), + ( + + ) + ]} >
{t('import.selectAccount')}
diff --git a/project/aitoearn-web/src/app/[lng]/login/page.tsx b/project/aitoearn-web/src/app/[lng]/login/page.tsx index 17a30e9d0..cd9a236cc 100644 --- a/project/aitoearn-web/src/app/[lng]/login/page.tsx +++ b/project/aitoearn-web/src/app/[lng]/login/page.tsx @@ -135,7 +135,7 @@ export default function LoginPage() { setUserInfo(response.data.userInfo); } message.success(t('loginSuccess')); - router.push('/'); + router.push('/accounts'); } } else { message.error(response.message || t('googleLoginFailed')); diff --git a/project/aitoearn-web/src/app/[lng]/material/ai-generate/ai-generate.module.scss b/project/aitoearn-web/src/app/[lng]/material/ai-generate/ai-generate.module.scss index 22a1403cc..fef6ce44f 100644 --- a/project/aitoearn-web/src/app/[lng]/material/ai-generate/ai-generate.module.scss +++ b/project/aitoearn-web/src/app/[lng]/material/ai-generate/ai-generate.module.scss @@ -283,8 +283,8 @@ .sampleDotActive { background: var(--theColor5); } // 视频首尾帧上传面板 -.uploadPanel { display: flex; align-items: center; gap: 12px; width: 100%; } -.uploadCard { flex: 1; height: 120px; border: 1px dashed rgba(0,0,0,.15); border-radius: 8px; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden; } +.uploadPanel { flex-direction: row; align-items: center; gap: 12px; width: 100%; } +.uploadCard { flex: 1; height: 120px; padding: 10px 0; border: 1px dashed rgba(0,0,0,.15); border-radius: 8px; background: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer; overflow: hidden; } .uploadCard img { max-width: 100%; max-height: 100%; object-fit: cover; } .uploadPlaceholder { display: flex; flex-direction: column; align-items: center; gap: 8px; color: #999; } .uploadIcon { width: 28px; height: 28px; border-radius: 14px; background: rgba(166,106,228,.12); color: var(--theColor5); display: inline-flex; align-items: center; justify-content: center; font-weight: 600; } @@ -548,4 +548,68 @@ color: #1890ff; font-weight: 500; } +} + +// 多图上传样式 +.multiImageUpload { + display: flex; + flex-direction: column; + gap: 12px; +} + +.imageGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); + gap: 8px; + margin-top: 8px; +} + +.imageItem { + position: relative; + width: 80px; + height: 80px; + border-radius: 8px; + border: 1px solid #e8e8e8; + + img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .removeBtn { + position: absolute; + top: -6px; + right: -6px; + width: 20px; + height: 20px; + border-radius: 50%; + background-color: #ff4d4f; + color: white; + border: none; + font-size: 14px; + font-weight: bold; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: all 0.2s ease; + + &:hover { + background-color: #ff7875; + transform: scale(1.1); + } + } +} + +.clearAllBtn { + background: none; + border: none; + cursor: pointer; + text-decoration: underline; + + &:hover { + color: #ff7875; + } } \ No newline at end of file diff --git a/project/aitoearn-web/src/app/[lng]/material/ai-generate/page.tsx b/project/aitoearn-web/src/app/[lng]/material/ai-generate/page.tsx index a4f0befc4..db6484101 100644 --- a/project/aitoearn-web/src/app/[lng]/material/ai-generate/page.tsx +++ b/project/aitoearn-web/src/app/[lng]/material/ai-generate/page.tsx @@ -12,6 +12,7 @@ import { getMediaGroupList, createMedia } from "@/api/media"; import { useTransClient } from "@/app/i18n/client"; import { md2CardTemplates, defaultMarkdown } from "./md2card"; import Chat from "@/components/Chat"; +import { OSS_URL } from "@/constant"; const { TextArea } = Input; const { Option } = Select; @@ -42,15 +43,15 @@ export default function AIGeneratePage() { // 根据 URL 初始化模块与子标签 const queryTab = (searchParams.get("tab") || "").toString(); - const initIsVideo = ["videoGeneration", "text2video", "image2video", "flf2video", "lf2video", "multi-image2video"].includes(queryTab); + const initIsVideo = ["videoGeneration", "text2video", "image2video"].includes(queryTab); const initImageTab = ["textToImage", "textToFireflyCard", "md2card", "chat"].includes(queryTab) ? (queryTab as any) : "textToImage"; - const initVideoTab = ["image2video", "flf2video", "lf2video", "multi-image2video"].includes(queryTab) ? (queryTab as any) : "text2video"; + const initVideoTab = ["image2video"].includes(queryTab) ? (queryTab as any) : "text2video"; // 左侧模块切换 const [activeModule, setActiveModule] = useState<"image" | "video">(initIsVideo ? "video" : "image"); // 图片子模块切换 const [activeImageTab, setActiveImageTab] = useState<"textToImage" | "textToFireflyCard" | "md2card" | "chat">(initImageTab); // 视频子模块切换 - const [activeVideoTab, setActiveVideoTab] = useState<"text2video" | "image2video" | "flf2video" | "lf2video" | "multi-image2video">(initVideoTab); + const [activeVideoTab, setActiveVideoTab] = useState<"text2video" | "image2video">(initVideoTab); // 文生图 const [prompt, setPrompt] = useState(""); @@ -93,6 +94,7 @@ export default function AIGeneratePage() { const [videoMode, setVideoMode] = useState("text2video"); const [videoImage, setVideoImage] = useState(""); const [videoImageTail, setVideoImageTail] = useState(""); + const [videoImages, setVideoImages] = useState([]); const [loadingVideo, setLoadingVideo] = useState(false); const [videoTaskId, setVideoTaskId] = useState(null); const [videoStatus, setVideoStatus] = useState(""); @@ -145,6 +147,49 @@ export default function AIGeneratePage() { finally { setUploadingTailFrame(false); if (e.target) e.target.value = ""; } }; + // 多图上传处理 + const handleMultiImageChange = async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + const validFiles: File[] = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (checkFileSize(file) && checkImageFormat(file)) { + validFiles.push(file); + } + } + + if (validFiles.length === 0) { + if (e.target) e.target.value = ""; + return; + } + + try { + setUploadingFirstFrame(true); + const uploadPromises = validFiles.map(file => uploadToOss(file)); + const keys = await Promise.all(uploadPromises); + const urls = keys.map(key => getOssUrl(key)); + setVideoImages(prev => [...prev, ...urls]); + message.success(`成功上传 ${validFiles.length} 张图片`); + } catch (error) { + message.error(t("aiGenerate.uploadFailed")); + } finally { + setUploadingFirstFrame(false); + if (e.target) e.target.value = ""; + } + }; + + // 删除图片 + const handleRemoveImage = (index: number) => { + setVideoImages(prev => prev.filter((_, i) => i !== index)); + }; + + // 清空所有图片 + const handleClearAllImages = () => { + setVideoImages([]); + }; + // md2card const [markdownContent, setMarkdownContent] = useState(defaultMarkdown); const [selectedTheme, setSelectedTheme] = useState("apple-notes"); @@ -225,10 +270,13 @@ export default function AIGeneratePage() { const filteredVideoModels = useMemo(() => { if (!Array.isArray(videoModels)) return [] as any[]; if (videoMode === "text2video") return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("text2video")); - if (videoMode === "image2video") return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("image2video")); - if (videoMode === "flf2video") return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("flf2video")); - if (videoMode === "lf2video") return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("lf2video")); - if (videoMode === "multi-image2video") return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("multi-image2video")); + if (videoMode === "image2video") { + // 合并所有支持图片的视频模式 + return (videoModels as any[]).filter((m: any) => { + const modes = m?.modes || []; + return modes.includes("image2video") || modes.includes("flf2video") || modes.includes("lf2video") || modes.includes("multi-image2video"); + }); + } return (videoModels as any[]).filter((m: any) => (m?.modes || []).includes("image2video")); }, [videoModels, videoMode]); @@ -246,7 +294,7 @@ export default function AIGeneratePage() { setVideoModel(first.name); if (first?.durations?.length) setVideoDuration(first.durations[0]); if (first?.resolutions?.length) setVideoSize(first.resolutions[0]); - if (first?.modes?.includes("image2video")) setVideoMode("image2video"); + if (first?.modes?.includes("image2video") || first?.modes?.includes("flf2video") || first?.modes?.includes("lf2video") || first?.modes?.includes("multi-image2video")) setVideoMode("image2video"); else if (first?.modes?.includes("text2video")) setVideoMode("text2video"); } } @@ -338,7 +386,12 @@ export default function AIGeneratePage() { if (!tab) return; if (tab === 'videoGeneration' || tab === 'text2video' || tab === 'image2video' || tab === 'flf2video' || tab === 'lf2video' || tab === 'multi-image2video') { setActiveModule('video'); - setActiveVideoTab(tab as any); + // 将所有图片相关的视频模式都映射到 image2video + if (tab === 'flf2video' || tab === 'lf2video' || tab === 'multi-image2video') { + setActiveVideoTab('image2video'); + } else { + setActiveVideoTab(tab as any); + } } else if (tab === 'textToImage' || tab === 'textToFireflyCard' || tab === 'md2card' || tab === 'chat') { setActiveModule('image'); setActiveImageTab(tab as any); @@ -351,10 +404,7 @@ export default function AIGeneratePage() { setVideoModel((filteredVideoModels as any[])[0].name); } } - if (videoMode === "text2video") { setVideoImage(""); setVideoImageTail(""); } - if (videoMode === "flf2video") { setVideoImage(""); setVideoImageTail(""); } - if (videoMode === "lf2video") { setVideoImage(""); setVideoImageTail(""); } - if (videoMode === "multi-image2video") { setVideoImage(""); setVideoImageTail(""); } + if (videoMode === "text2video") { setVideoImage(""); setVideoImageTail(""); setVideoImages([]); } }, [videoMode, filteredVideoModels]); useEffect(() => { @@ -439,14 +489,45 @@ export default function AIGeneratePage() { catch { message.error(t("aiGenerate.fireflyCardGenerationFailed")); } finally { setLoadingFirefly(false); } }; + const replaceOssUrl = (url: string) => { + return url.replace("/ossProxy/", OSS_URL); + }; + const handleVideoGeneration = async () => { if (!videoPrompt) { message.error(t("aiGenerate.pleaseEnterVideoDescription")); return; } if (!videoModel) { message.error(t("aiGenerate.pleaseSelectVideoModel")); return; } - if (videoMode === "image2video" || videoMode === "flf2video" || videoMode === "lf2video" || videoMode === "multi-image2video") { + if (videoMode === "image2video") { const current: any = (filteredVideoModels as any[]).find((m: any) => m.name === videoModel) || {}; const supported: string[] = current?.supportedParameters || []; - if (supported.includes("image") && !videoImage) { message.error(t("aiGenerate.pleaseUploadFirstFrame")); return; } - if (supported.includes("image_tail") && !videoImageTail) { message.error(t("aiGenerate.pleaseUploadTailFrame")); return; } + const modes: string[] = current?.modes || []; + + // 检查多图合成视频 + if (modes.includes('multi-image2video') && supported.includes("image") && videoImages.length === 0) { + message.error("请上传至少一张图片"); + return; + } + + // 检查单图视频 + if (modes.includes('image2video') && !modes.includes('multi-image2video') && supported.includes("image") && !videoImage) { + message.error(t("aiGenerate.pleaseUploadFirstFrame")); + return; + } + + // 检查首尾帧视频 + if (modes.includes('flf2video') && supported.includes("image") && !videoImage) { + message.error(t("aiGenerate.pleaseUploadFirstFrame")); + return; + } + if (modes.includes('flf2video') && supported.includes("image_tail") && !videoImageTail) { + message.error(t("aiGenerate.pleaseUploadTailFrame")); + return; + } + + // 检查仅尾帧视频 + if (modes.includes('lf2video') && supported.includes("image_tail") && !videoImageTail) { + message.error(t("aiGenerate.pleaseUploadTailFrame")); + return; + } } try { setLoadingVideo(true); setVideoStatus("submitted"); setVideoProgress(10); @@ -461,9 +542,27 @@ export default function AIGeneratePage() { } const supported: string[] = current?.supportedParameters || []; - if (videoMode === "image2video" || videoMode === "flf2video" || videoMode === "lf2video" || videoMode === "multi-image2video") { - if (supported.includes("image") && videoImage) data.image = videoImage; - if (supported.includes("image_tail") && videoImageTail) data.image_tail = videoImageTail; + const modes: string[] = current?.modes || []; + + if (videoMode === "image2video") { + // 多图合成视频 + if (modes.includes('multi-image2video') && supported.includes("image") && videoImages.length > 0) { + data.image = videoImages.map((image: string) => replaceOssUrl(image)); + } + // 单图视频 + else if (modes.includes('image2video') && !modes.includes('multi-image2video') && supported.includes("image") && videoImage) { + + data.image = replaceOssUrl(videoImage); + } + // 首尾帧视频 + else if (modes.includes('flf2video')) { + if (supported.includes("image") && videoImage) data.image = replaceOssUrl(videoImage); + if (supported.includes("image_tail") && videoImageTail) data.image_tail = replaceOssUrl(videoImageTail); + } + // 仅尾帧视频 + else if (modes.includes('lf2video') && supported.includes("image_tail") && videoImageTail) { + data.image_tail = replaceOssUrl(videoImageTail); + } } const res: any = await generateVideo(data); if (res.data?.task_id) { setVideoTaskId(res.data.task_id); setVideoStatus(res.data.status); message.success(t("aiGenerate.taskSubmittedSuccess")); pollVideoTaskStatus(res.data.task_id); } @@ -558,7 +657,7 @@ export default function AIGeneratePage() { setLoadingMd2Card(true); const res: any = await generateMd2Card({ markdown: markdownContent, theme: selectedTheme, themeMode, width: cardWidth, height: cardHeight, splitMode, mdxMode, overHiddenMode }); if (res.data?.images?.length) { - const cardUrl = res.data.images[0].url; + const cardUrl = res.data.image[0].url; setMd2CardResult(cardUrl); // 自动上传到默认素材库组 @@ -701,18 +800,6 @@ export default function AIGeneratePage() {
{t('aiGenerate.imageToVideo')}
- - -
)} @@ -976,10 +1063,7 @@ export default function AIGeneratePage() {
{activeVideoTab==='text2video' ? t('aiGenerate.textToVideo') : - activeVideoTab==='image2video' ? t('aiGenerate.imageToVideo') : - activeVideoTab==='flf2video' ? 'FLF2Video' : - activeVideoTab==='lf2video' ? 'LF2Video' : - activeVideoTab==='multi-image2video' ? 'Multi-Image2Video' : t('aiGenerate.textToVideo')} + activeVideoTab==='image2video' ? t('aiGenerate.imageToVideo') : t('aiGenerate.textToVideo')}
{(() => { if (videoMode !== activeVideoTab) setVideoMode(activeVideoTab); return null; })()} @@ -1055,40 +1139,129 @@ export default function AIGeneratePage() { })()}
- {(()=>{ const selected:any=(filteredVideoModels as any[]).find((m:any)=>m.name===videoModel)||{}; const supported:string[]=selected?.supportedParameters||[]; return (<> - {(videoMode==='image2video' || videoMode==='flf2video' || videoMode==='lf2video' || videoMode==='multi-image2video') && supported.includes('image') && ( -
-
- - {videoImage ? ( - {t('aiGenerate.firstFrame')} - ) : ( -
- + - {t('aiGenerate.firstFrame')} + {(()=>{ + const selected:any=(filteredVideoModels as any[]).find((m:any)=>m.name===videoModel)||{}; + const supported:string[]=selected?.supportedParameters||[]; + const modes:string[]=selected?.modes||[]; + + // 根据 modes 判断视频生成类型 + const isImage2Video = modes.includes('image2video'); + const isFlf2Video = modes.includes('flf2video'); + const isLf2Video = modes.includes('lf2video'); + const isMultiImage2Video = modes.includes('multi-image2video'); + + return (<> + {videoMode==='image2video' && ( +
+ {/* 单张图生视频 - 只需要首帧 */} + {isImage2Video && supported.includes('image') && !supported.includes('image_tail') && ( +
+ {videoImage ? ( + {t('aiGenerate.firstFrame')} + ) : ( +
+ + + 上传图片 +
+ )} +
)} - -
- {supported.includes('image_tail') && ( -
- )} - {(videoMode==='image2video' || videoMode==='flf2video' || videoMode==='lf2video' || videoMode==='multi-image2video') && supported.includes('image_tail') && ( -
- {videoImageTail ? ( - {t('aiGenerate.tailFrame')} - ) : ( -
- + - {t('aiGenerate.tailFrame')} + + {/* 多图合成视频 - 需要多张图片 */} + {isMultiImage2Video && supported.includes('image') && ( +
+
firstFrameInputRef.current?.click()}> +
+ + + 添加图片 +
+
- )} - -
- )} -
- )} - ); })()} + + {videoImages.length > 0 && ( +
+ {videoImages.map((img, index) => ( +
+ {`图片 + +
+ ))} +
+ )} + + {videoImages.length > 0 && ( + + )} +
+ )} + + {/* 首尾帧生成视频 - 需要首帧和尾帧 */} + {isFlf2Video && supported.includes('image') && supported.includes('image_tail') && ( + <> +
+ {videoImage ? ( + {t('aiGenerate.firstFrame')} + ) : ( +
+ + + {t('aiGenerate.firstFrame')} +
+ )} + +
+
+
+ {videoImageTail ? ( + {t('aiGenerate.tailFrame')} + ) : ( +
+ + + {t('aiGenerate.tailFrame')} +
+ )} + +
+ + )} + + {/* 仅尾帧生成视频 - 只需要尾帧 */} + {isLf2Video && supported.includes('image_tail') && !supported.includes('image') && ( +
+ {videoImageTail ? ( + {t('aiGenerate.tailFrame')} + ) : ( +
+ + + {t('aiGenerate.tailFrame')} +
+ )} + +
+ )} +
+ )} + ); + })()}
{videoModel && videoDuration && videoSize && (
💰 {t('aiGenerate.estimatedCreditCost' as any)}: {getVideoModelCreditCost(videoModel, videoDuration, videoSize)} {t('aiGenerate.credits' as any)}
diff --git a/project/aitoearn-web/src/app/i18n/locales/en/cgmaterial.json b/project/aitoearn-web/src/app/i18n/locales/en/cgmaterial.json index 39d5147eb..a22f0d567 100644 --- a/project/aitoearn-web/src/app/i18n/locales/en/cgmaterial.json +++ b/project/aitoearn-web/src/app/i18n/locales/en/cgmaterial.json @@ -97,7 +97,37 @@ "importSuccess": "Imported {{count}} items", "importFailed": "Import failed", "getAccountsFailed": "Get accounts failed", - "getPublishListFailed": "Get publish list failed" + "getPublishListFailed": "Get publish list failed", + "checkProgress": "Check Import", + "importRecords": "Import Records", + "importStatus": "Import Status:", + "successCount": "Success {{count}}", + "failedCount": "Failed {{count}}", + "runningCount": "Running {{count}}", + "pendingCount": "Pending {{count}}", + "noRecords": "No import records", + "checkStatusFailed": "Check status failed", + "accountId": "Account ID", + "userId": "User ID", + "uid": "UID", + "publishTime": "Publish Time", + "mediaType": "Media Type", + "image": "Image", + "video": "Video", + "article": "Article", + "desc": "Description", + "interactionData": "Interaction Data", + "views": "Views", + "likes": "Likes", + "comments": "Comments", + "shares": "Shares", + "cover": "Cover", + "close": "Close", + "noTitle": "No Title", + "success": "Success", + "failed": "Failed", + "running": "Running", + "pending": "Pending" }, "detail": { "title": "Material Detail", diff --git a/project/aitoearn-web/src/app/i18n/locales/zh-CN/cgmaterial.json b/project/aitoearn-web/src/app/i18n/locales/zh-CN/cgmaterial.json index 57c90190c..daf3e7ec3 100644 --- a/project/aitoearn-web/src/app/i18n/locales/zh-CN/cgmaterial.json +++ b/project/aitoearn-web/src/app/i18n/locales/zh-CN/cgmaterial.json @@ -97,7 +97,37 @@ "importSuccess": "成功导入 {{count}} 条内容到草稿箱", "importFailed": "导入失败", "getAccountsFailed": "获取账户列表失败", - "getPublishListFailed": "获取发布列表失败" + "getPublishListFailed": "获取发布列表失败", + "checkProgress": "导入查询", + "importRecords": "导入记录详情", + "importStatus": "导入状态:", + "successCount": "成功 {{count}} 条", + "failedCount": "失败 {{count}} 条", + "runningCount": "进行中 {{count}} 条", + "pendingCount": "等待中 {{count}} 条", + "noRecords": "暂无导入记录", + "checkStatusFailed": "查询导入状态失败", + "accountId": "账户ID", + "userId": "用户ID", + "uid": "UID", + "publishTime": "发布时间", + "mediaType": "媒体类型", + "image": "图片", + "video": "视频", + "article": "文章", + "desc": "描述", + "interactionData": "互动数据", + "views": "浏览", + "likes": "点赞", + "comments": "评论", + "shares": "分享", + "cover": "封面", + "close": "关闭", + "noTitle": "无标题", + "success": "成功", + "failed": "失败", + "running": "进行中", + "pending": "等待中" }, "detail": { "title": "素材详情", diff --git a/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/bug_report.yml b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..38ad1d6df --- /dev/null +++ b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,64 @@ +name: "🕷️ Bug report" +description: Report errors or unexpected behavior +labels: + - bug +body: + - type: checkboxes + attributes: + label: Self Checks + description: "To make sure we get to you in time, please check the following :)" + options: + - label: I have read the [Contributing Guide](https://github.com/yikart/AiToEarn/blob/main/CONTRIBUTING.md). + required: true + - label: I have searched for existing issues [search for existing issues](https://github.com/yikart/AiToEarn/issues), including closed ones. + required: true + - label: I confirm that I am using English to submit this report, otherwise it will be closed. + required: true + - label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :) + required: true + - label: "Please do not modify this template :) and fill in all the required fields." + required: true + + - type: input + attributes: + label: Aitoearn version + description: + validations: + required: true + + - type: dropdown + attributes: + label: Please select your platform + description: + multiple: true + options: + - Android + - Windows + - Mac + - Web + validations: + required: true + + - type: textarea + attributes: + label: Steps to reproduce + description: We highly suggest including screenshots and a bug report log. Please use the right markdown syntax for code blocks. + placeholder: Having detailed steps helps us reproduce the bug. If you have logs, please use fenced code blocks (triple backticks ```) to format them. + validations: + required: true + + - type: textarea + attributes: + label: ✔️ Expected Behavior + description: Describe what you expected to happen. + placeholder: What were you expecting? Please do not copy and paste the steps to reproduce here. + validations: + required: true + + - type: textarea + attributes: + label: ❌ Actual Behavior + description: Describe what actually happened. + placeholder: What happened instead? Please do not copy and paste the steps to reproduce here. + validations: + required: false diff --git a/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/feature_request.yml b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..63a209d31 --- /dev/null +++ b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,40 @@ +name: "⭐ Feature or enhancement request" +description: Propose something new. +labels: + - enhancement +body: + - type: checkboxes + attributes: + label: Self Checks + description: "To make sure we get to you in time, please check the following :)" + options: + - label: I have read the [Contributing Guide](https://github.com/yikart/AiToEarn/blob/main/CONTRIBUTING.md). + required: true + - label: I have searched for existing issues [search for existing issues](https://github.com/yikart/AiToEarn/issues), including closed ones. + required: true + - label: I confirm that I am using English to submit this report, otherwise it will be closed. + required: true + - label: "Please do not modify this template :) and fill in all the required fields." + required: true + - type: textarea + attributes: + label: 1. Is this request related to a challenge you're experiencing? Tell me about your story. + placeholder: Please describe the specific scenario or problem you're facing as clearly as possible. For instance "I was trying to use [feature] for [specific task], and [what happened]... It was frustrating because...." + validations: + required: true + - type: textarea + attributes: + label: 2. Additional context or comments + placeholder: (Any other information, comments, documentations, links, or screenshots that would provide more clarity. This is the place to add anything else not covered above.) + validations: + required: false + - type: checkboxes + attributes: + label: 3. Can you help us with this feature? + description: Let us know! This is not a commitment, but a starting point for collaboration. + options: + - label: I am interested in contributing to this feature. + required: false + - type: markdown + attributes: + value: Please limit one request per issue. diff --git a/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/refactor.yml b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/refactor.yml new file mode 100644 index 000000000..7c08dd391 --- /dev/null +++ b/project/aitoearn-wxplat/.github/ISSUE_TEMPLATE/refactor.yml @@ -0,0 +1,41 @@ +name: "✨ Refactor" +description: Refactor existing code for improved readability and maintainability. +labels: + - refactor +body: + - type: checkboxes + attributes: + label: Self Checks + description: "To make sure we get to you in time, please check the following :)" + options: + - label: I have read the [Contributing Guide](https://github.com/yikart/AiToEarn/blob/main/CONTRIBUTING.md). + required: true + - label: I have searched for existing issues [search for existing issues](https://github.com/yikart/AiToEarn/issues), including closed ones. + required: true + - label: I confirm that I am using English to submit this report, otherwise it will be closed. + required: true + - label: 【中文用户 & Non English User】请使用英语提交,否则会被关闭 :) + required: true + - label: "Please do not modify this template :) and fill in all the required fields." + required: true + - type: textarea + id: description + attributes: + label: Description + placeholder: "Describe the refactor you are proposing." + validations: + required: true + - type: textarea + id: motivation + attributes: + label: Motivation + placeholder: "Explain why this refactor is necessary." + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: Additional Context + placeholder: "Add any other context or screenshots about the request here." + validations: + required: false diff --git a/project/aitoearn-wxplat/.github/languages.yml b/project/aitoearn-wxplat/.github/languages.yml new file mode 100644 index 000000000..841c9fe3c --- /dev/null +++ b/project/aitoearn-wxplat/.github/languages.yml @@ -0,0 +1,2 @@ +l10n: + default_locale: zh-CN diff --git a/project/aitoearn-wxplat/.gitignore b/project/aitoearn-wxplat/.gitignore new file mode 100644 index 000000000..520269612 --- /dev/null +++ b/project/aitoearn-wxplat/.gitignore @@ -0,0 +1,47 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dist-electron +release +*.local +.vscode +# Editor directories and files +.vscode/.debug.env +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# .env + +#lockfile +# package-lock.json +# pnpm-lock.yaml +# yarn.lock +/test-results/ +/playwright-report/ +/playwright/.cache/ + +# 忽略所有.db文件 +*.db + +# 忽略所有.exe文件 +*.exe + +# 忽略/public/bin目录下的非.md文件 +public/bin/* +!public/bin/webkit +!public/bin/*.md + +sh/* \ No newline at end of file diff --git a/project/aitoearn-wxplat/CONTRIBUTING.md b/project/aitoearn-wxplat/CONTRIBUTING.md new file mode 100644 index 000000000..e40fdf2f3 --- /dev/null +++ b/project/aitoearn-wxplat/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contribution Guide + +Thank you for your interest in the AiToEarn project! We welcome all forms of contributions, including but not limited to bug reports, feature requests, code improvements, and documentation enhancements. + +## Table of Contents + +- [Contribution Guide](#contribution-guide) + - [Table of Contents](#table-of-contents) + - [Code of Conduct](#code-of-conduct) + - [Development Environment Setup](#development-environment-setup) + - [Submitting Pull Requests](#submitting-pull-requests) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting New Features](#suggesting-new-features) + - [Code Review](#code-review) + +## Code of Conduct + +Please follow our code of conduct to ensure all participants can communicate in an open and friendly environment. + +## Development Environment Setup + +1. Fork the project to your own GitHub account +2. Create a feature branch: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Commit your changes: + ```bash + git add . + git commit -m "feat: add new feature" + ``` +4. Push to your forked repository: + ```bash + git push origin feature/your-feature-name + ``` +5. Create a Pull Request on GitHub + +## Reporting Bugs +1. Ensure the bug has not been reported in other issues +2. Create a new issue and include the following information: + - Title describing the bug + - Detailed description of the bug + - Steps to reproduce the bug + - Screenshots (if available) + - Other information (such as error logs, stack traces, etc.) +## Suggesting New Features +1. Ensure the feature has not been requested in other issues +2. Create a new issue and include the following information: + - Title describing the new feature + - Detailed description of the new feature + - Screenshots (if available) +## Code Review +1. Create a pull request +2. Wait for other contributors to review the code +3. Modify the code and commit the new changes +4. Push the new commits to your forked repository +5. Ensure the pull request is merged into the main repository diff --git a/project/aitoearn-wxplat/LICENSE.txt b/project/aitoearn-wxplat/LICENSE.txt new file mode 100644 index 000000000..63cbe8623 --- /dev/null +++ b/project/aitoearn-wxplat/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 AiToEarn + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/project/aitoearn-wxplat/README.md b/project/aitoearn-wxplat/README.md new file mode 100644 index 000000000..f064cc43c --- /dev/null +++ b/project/aitoearn-wxplat/README.md @@ -0,0 +1,230 @@ + +# [Aitoearn: The Best Open-Source AI Agent for Content Marketing](https://aitoearn.ai) + + +![GitHub stars](https://img.shields.io/github/stars/yikart/AttAiToEarn?color=fa6470) +![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg) +[![Required Node.JS 20.18.x](https://img.shields.io/static/v1?label=node\&message=20.18.x%20\&logo=node.js\&color=3f893e)](https://nodejs.org/about/releases) + +[简体中文](README_CN.md) | English + + + +**Create · Publish · Engage · Monetize — all in one platform.** + +AiToEarn helps creators, brands, and businesses build, distribute, and monetize content with **AI-powered automation** across the world’s most popular platforms. + +Supported Channels: +Douyin, Xiaohongshu (Rednote), WeChat Channels, Kuaishou, Bilibili, WeChat Official Accounts, +TikTok, YouTube, Facebook, Instagram, Threads, Twitter (X), Pinterest + +
+

Table of Contents

+ +
+ + 1. [Quick Start](#quick-start) + 2. [Start Web Project](#start-web-project) + 3. [Start Electron Project](#start-electron-project) + 4. [Key Features](#key-features) + 5. [MCP Service](#mcp-service) + 6. [Advanced Setup](#advanced-setup) + 7. [Contribution Guide](#contribution-guide) + 8. [Contact](#contact) + 9. [Milestones](#milestones) + 10. [FAQ](#faq) + 11. [Recommended](#recommended) +
+ + +## Quick Start + +OS | Download +-- | -- +Android | [![Download Android](https://img.shields.io/badge/APK-Android1.1.0-green?logo=android&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/aitoearn-1.1.0.apk) +Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.1.0-blue?logo=windows&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarnSetup-1.1.0.exe) +macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.1.0-black?logo=apple&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarn.1.1.0.dmg) +iOS | **Coming soon!** +Web | [Use on Web](https://aitoearn.ai/en/accounts) + +[Google Play Download](https://play.google.com/store/apps/details?id=com.yika.aitoearn.aitoearn_app) + + + + +## Start Web Project +### 1. Start the backend service + +```bash +cd project/aitoearn-monorepo +pnpm install +npx nx serve aitoearn-channel && npx nx serve aitoearn-server +``` + +### 2. Start the frontend `aitoearn-web` + +```bash +pnpm install +pnpm run dev +``` + + +## Start Electron Project + +```sh +# Clone the repo +git clone https://github.com/yikart/AttAiToEarn.git + +# Enter directory +cd AttAiToEarn + +# Install dependencies +npm i + +# Compile sqlite (better-sqlite3 requires node-gyp, Python must be installed locally) +npm run rebuild + +# Start development +npm run dev +``` + + +## Key Features + +🚀 **AiToEarn is a full-stack AI-powered content growth & monetization platform.** +From creative ideas, to multi-channel publishing, to analytics & monetization — AiToEarn helps you truly **Create · Publish · Engage · Monetize.** + + +### 1. Content Publishing — One-Click Multi-Platform + +* **Distribute Everywhere**: Publish to the widest range of global platforms (Douyin, Kwai, WeChat Channels, WeChat Offical Account, Bilibili, Rednote, Facebook, Instagram, TikTok, LinkedIn, Threads, YouTube, Pinterest, x(Twitter)). +* **(Coming soon) Smart Import**: Import historical content for fast re-editing & redistribution. + + * Example: Sync your Xiaohongshu posts to YouTube in one click. +* **Calendar Scheduler**: Plan & coordinate content like a calendar across all platforms. +
+ + +
+ +### 2. Content Hotspot — Viral Inspiration Engine + +* **Case Library**: Explore how others create posts with 10,000+ likes. +* **Trend Radar**: Discover the latest viral trends instantly, reduce creator anxiety. +
+ + + + +
+ +### 3. Content Search — Brand & Market Insights + +* **Brand Monitoring**: Track conversations about your brand in real-time. +* **Content Discovery**: Search for posts, topics, and communities for targeted engagement. + +
+ + + + +
+ + +### 4. Comments Search — Precision User Mining + +* **Smart Comment Search**: Detect high-conversion signals like “link please” or “how to buy.” +* **Conversion Booster**: Reply instantly, drive higher engagement & sales. +
+ + +
+ +### 5. Content Engagement — Growth Engine + +* **Unified Dashboard**: Manage all interactions in one place. +* **Proactive Engagement**: Join trending conversations, connect with potential customers. +* Turn **passive operations** into **active traffic growth.** + +
+ +
+ +### 6. (Coming Soon) Content Analytics — Full-Funnel Data + +* **Cross-Platform Comparison**: One platform may block traffic, but others won’t. +* **End-to-End Monitoring**: Track performance and build your path to 1M+ followers. + +post + +### 7. (Coming Soon) AI Content Creation — End-to-End Assistant + +* **AI Copywriting**: Auto-generate titles, captions & descriptions. +* **AI Commenting**: Engage proactively, attract traffic. +* **Image & Card Generator**: Speed up content workflows. +* **Supported AI Video Models**: Seedance, Kling, Hailuo, Veo, Medjourney, Sora, Pika, Runway. +* **Supported AI Image Models**: GPT, Flux. +* **Next**: Tag generator, smart DMs, video editing, AI avatars, translation for global distribution. + + +### 8. (Coming Soon) Content Marketplace — Trade & Monetize + +* **Creators**: Sell your content directly, find buyers fast. +* **Brands**: Purchase ready-made, high-quality content. +* **AI-Powered Growth**: + **Let’s use AI to earn. Let’s earn money together!** + + +## MCP Service +https://www.modelscope.cn/mcp/servers/whh826219822/aitoearn +https://www.npmjs.com/~aitoearn?activeTab=packages + + +## Advanced Setup + +AiToEarn integrates with many official APIs. Developer key setup guides: + +* [Bilibili](./aitoearn_web/CHANNEL_Md/BILIBILI.md) +* [WeChat Official Accounts](./aitoearn_web/CHANNEL_Md/WXPLAT.md) + + +## Contribution Guide + +See [Contribution Guide](./aitoearn_web/CONTRIBUTING.md) to get started. + + +## Contact +https://t.me/harryyyy2025 + + +## Milestones + +* 2025.02.26 — Released win-0.1.1 +* 2025.03.15 — Released win-0.2.0 +* 2025.04.18 — Released win-0.6.0 +* 2025.05.20 — Released win-0.8.0 +* 2025.08.08 — [Released win-0.8.1](https://github.com/yikart/AiToEarn/releases/tag/v0.8.1) +* 2025.08.08 — [Released web-0.1-beta](./aitoearn_web/README.md) +* 2025.09.16 — [Released v1.0.18](https://github.com/yikart/AiToEarn/releases/tag/v1.0.18) +* 2025.10.01 — [Released v1.0.27](https://github.com/yikart/AiToEarn/releases/tag/v1.0.27) + +--- + +## [FAQ](https://heovzp8pm4.feishu.cn/wiki/UksHwxdFai45SvkLf0ycblwRnTc?from=from_copylink) + + +## Recommended + +**[AWS Activate Program](https://www.amazonaws.cn/en/campaign/ps-yunchuang/)** + +**[AI Model Hub](https://api.zyai.online/)** + +* [https://github.com/TMElyralab/MuseTalk](https://github.com/TMElyralab/MuseTalk) +* [https://github.com/5ime/video\_spider](https://github.com/5ime/video_spider) +* [https://github.com/FunAudioLLM/CosyVoice?tab=readme-ov-file](https://github.com/FunAudioLLM/CosyVoice?tab=readme-ov-file) +* [https://github.com/facefusion/facefusion](https://github.com/facefusion/facefusion) +* [https://github.com/linyqh/NarratoAI](https://github.com/linyqh/NarratoAI) +* [https://github.com/harry0703/MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo) + + + diff --git a/project/aitoearn-wxplat/README_CN.md b/project/aitoearn-wxplat/README_CN.md new file mode 100644 index 000000000..49fbebaac --- /dev/null +++ b/project/aitoearn-wxplat/README_CN.md @@ -0,0 +1,198 @@ + +# [Aitoearn:最佳开源 AI 内容营销智能体](https://aitoearn.ai) + +![GitHub stars](https://img.shields.io/github/stars/yikart/AttAiToEarn?color=fa6470) +![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg) +[![Required Node.JS 20.18.x](https://img.shields.io/static/v1?label=node\&message=20.18.x%20\&logo=node.js\&color=3f893e)](https://nodejs.org/about/releases) + +[简体中文](README_CN.md) | English + +**Create · Publish · Engage · Monetize —— 一站式平台。** + +AiToEarn 通过**AI 自动化**,帮助创作者、品牌与企业在全球主流平台上构建、分发并变现内容。 + +支持渠道: +抖音(Douyin)、小红书(Rednote)、视频号(WeChat Channels)、快手(Kuaishou)、哔哩哔哩(Bilibili)、微信公众号(WeChat Official Accounts)、TikTok、YouTube、Facebook、Instagram、Threads、Twitter(X)、Pinterest + +
+

目录

+ +
+ + 1. [快速开始](#quick-start) + 2. [启动 Web 项目](#start-web-project) + 3. [启动 Electron 项目](#start-electron-project) + 4. [核心功能](#key-features) + 5. [MCP 服务](#mcp-service) + 6. [高级设置](#advanced-setup) + 7. [贡献指南](#contribution-guide) + 8. [联系](#contact) + 9. [里程碑](#milestones) + 10. [常见问题](#faq) + 11. [推荐](#recommended) +
+ +

快速开始

+ +操作系统 | 下载 +-- | -- +Android | [![Download Android](https://img.shields.io/badge/APK-Android1.1.0-green?logo=android&logoColor=white)]((https://github.com/yikart/AiToEarn/releases/download/v1.1.0/aitoearn-1.1.0.apk)) +Windows | [![Download Windows](https://img.shields.io/badge/Setup-Windows1.1.0-blue?logo=windows&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarnSetup-1.1.0.exe) +macOS | [![Download macOS](https://img.shields.io/badge/DMG-macOS1.1.0-black?logo=apple&logoColor=white)](https://github.com/yikart/AiToEarn/releases/download/v1.1.0/AiToEarn.1.1.0.dmg) +iOS | **Coming soon!** +Web | [Use on Web](https://aitoearn.ai/en/accounts) + +[Google Play 下载](https://play.google.com/store/apps/details?id=com.yika.aitoearn.aitoearn_app) + + +

启动 Aitoearn 项目

+ +### 1. 启动后端服务 + +```bash +cd project/aitoearn-monorepo +pnpm install +npx nx serve aitoearn-channel && npx nx serve aitoearn-server +``` + +### 2. 启动前端 `aitoearn-web` + +```bash +cd project/aitoearn-web +pnpm install +pnpm run dev +``` + +

核心功能

+ +🚀 **AiToEarn 是一个全链条的 AI 驱动内容增长与变现平台。** +从创意灵感,到多平台分发,再到数据分析与变现——AiToEarn 让你真正实现 **Create · Publish · Engage · Monetize**。 + +### 1. 内容发布 —— 一键多平台 + +* **全网分发**:覆盖最广的平台矩阵(Douyin、Kwai、WeChat Channels、WeChat Offical Accounts、 Bilibili、Rednote、Facebook、Instagram、TikTok、LinkedIn、Threads、YouTube、Pinterest、X(Twitter))。 +* **(即将推出)智能导入**:导入历史内容,快速二次编辑与再分发。 + + * 例如:一键将你的小红书内容同步到 YouTube。 +* **日历排期**:像排日程一样统一规划所有平台的内容。 + +
+ + +
+ +### 2. 热点灵感 —— 爆款灵感引擎 + +* **案例库**:浏览 1 万+点赞量级内容的创作方法。 +* **趋势雷达**:第一时间捕捉热点,缓解创作者焦虑。 + +
+ + + + +
+ +### 3. 内容搜索 —— 品牌与市场洞察 + +* **品牌监测**:实时追踪关于你品牌的讨论。 +* **内容发现**:按主题、话题与社区检索,以更精准地参与互动。 + +
+ + + + +
+ +### 4. 评论搜寻 —— 精准用户挖掘 + +* **智能评论检索**:识别“求链接”“怎么购买”等高转化信号。 +* **转化加速器**:快速回复,驱动更高互动与销量。 + +
+ + +
+ +### 5. 互动运营 —— 增长引擎 + +* **统一工作台**:在一个界面管理全部互动。 +* **主动参与**:跟进热点话题,连接潜在用户。 + 将**被动运营**转变为**主动引流**。 + +
+ +
+ +### 6.(即将推出)数据分析 —— 全链路漏斗 + +* **跨平台对比**:某个平台限流?其他平台一样能打。 +* **端到端监控**:追踪表现,构建通往 100 万+粉丝的路线图。 + +数据中心 + +### 7.(即将推出)AI 内容创作 —— 端到端助手 + +* **AI 文案**:自动生成标题、文案与描述。 +* **AI 评论**:主动互动,吸引流量。 +* **图片与卡片生成**:加速内容工作流。 +* **支持的视频模型**:Seedance、Kling、海螺(Hailuo)、Veo、Medjourney、Sora、Pika、Runway。 +* **支持的图像模型**:GPT、Flux。 +* **下一步**:标签生成、智能私信、视频剪辑、AI 数字人、全球分发多语种翻译等。 + +### 8.(即将推出)内容交易市场 —— 创作即变现 + +* **创作者**:直接出售你的内容,高效找到买家。 +* **品牌方**:即买即用的优质内容资源。 +* **AI 驱动增长**: + **让我们用 AI 赚钱,一起赚!** + +

MCP 服务

+ +[https://www.modelscope.cn/mcp/servers/whh826219822/aitoearn](https://www.modelscope.cn/mcp/servers/whh826219822/aitoearn) +[https://www.npmjs.com/\~aitoearn?activeTab=packages](https://www.npmjs.com/~aitoearn?activeTab=packages) + +

高级设置

+ +AiToEarn 集成了多种官方 API。以下是开发者密钥配置指南: + +* [B 站(Bilibili)](./aitoearn_web/CHANNEL_Md/BILIBILI.md) +* [微信公众号(WeChat Official Accounts)](./aitoearn_web/CHANNEL_Md/WXPLAT.md) + +

贡献指南

+ +请查看 [贡献指南](./aitoearn_web/CONTRIBUTING.md) 开始参与。 + +

联系

+ +[https://t.me/harryyyy2025](https://t.me/harryyyy2025) + +

里程碑

+ +* 2025.02.26 — 发布 win-0.1.1 +* 2025.03.15 — 发布 win-0.2.0 +* 2025.04.18 — 发布 win-0.6.0 +* 2025.05.20 — 发布 win-0.8.0 +* 2025.08.08 — [发布 win-0.8.1](https://github.com/yikart/AiToEarn/releases/tag/v0.8.1) +* 2025.08.08 — [发布 web-0.1-beta](./aitoearn_web/README.md) +* 2025.09.16 — [发布 v1.0.18](https://github.com/yikart/AiToEarn/releases/tag/v1.0.18) +* 2025.10.01 — [发布 v1.0.27](https://github.com/yikart/AiToEarn/releases/tag/v1.0.27) + +--- +## [常见问题](https://docs.aitoearn.ai) + + + + +**[AWS Activate Program](https://www.amazonaws.cn/en/campaign/ps-yunchuang/)** + +**[AI Model Hub](https://api.zyai.online/)** + +* [https://github.com/TMElyralab/MuseTalk](https://github.com/TMElyralab/MuseTalk) +* [https://github.com/5ime/video\_spider](https://github.com/5ime/video_spider) +* [https://github.com/FunAudioLLM/CosyVoice?tab=readme-ov-file](https://github.com/FunAudioLLM/CosyVoice?tab=readme-ov-file) +* [https://github.com/facefusion/facefusion](https://github.com/facefusion/facefusion) +* [https://github.com/linyqh/NarratoAI](https://github.com/linyqh/NarratoAI) +* [https://github.com/harry0703/MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo) + diff --git a/project/aitoearn-wxplat/app-screenshot/1. content publish/calendar.jpeg b/project/aitoearn-wxplat/app-screenshot/1. content publish/calendar.jpeg new file mode 100644 index 000000000..b964f72a1 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/1. content publish/calendar.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/1. content publish/support_channels.jpeg b/project/aitoearn-wxplat/app-screenshot/1. content publish/support_channels.jpeg new file mode 100644 index 000000000..42cc29115 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/1. content publish/support_channels.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot.jpg b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot.jpg new file mode 100644 index 000000000..3520733b6 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot.jpg differ diff --git a/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot2.jpeg b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot2.jpeg new file mode 100644 index 000000000..e9b948662 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot2.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot3.jpeg b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot3.jpeg new file mode 100644 index 000000000..4ad538a71 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot3.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot4.jpeg b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot4.jpeg new file mode 100644 index 000000000..8ca16e49a Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/2. content hotspot/hotspot4.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch1.jpeg b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch1.jpeg new file mode 100644 index 000000000..6539f4b5d Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch1.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch2.jpeg b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch2.jpeg new file mode 100644 index 000000000..360e6195f Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch2.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch4.jpeg b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch4.jpeg new file mode 100644 index 000000000..1e7406850 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/3. content search/contentsearch4.jpeg differ diff --git a/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter.jpeg b/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter.jpeg new file mode 100644 index 000000000..3d6307ed5 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter.jpeg differ diff --git "a/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" "b/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" new file mode 100644 index 000000000..e249ba330 Binary files /dev/null and "b/project/aitoearn-wxplat/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" differ diff --git "a/project/aitoearn-wxplat/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" "b/project/aitoearn-wxplat/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" new file mode 100644 index 000000000..e249ba330 Binary files /dev/null and "b/project/aitoearn-wxplat/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" differ diff --git a/project/aitoearn-wxplat/app-screenshot/5. content engagement/commentfilter2.jpeg b/project/aitoearn-wxplat/app-screenshot/5. content engagement/commentfilter2.jpeg new file mode 100644 index 000000000..b28c546a8 Binary files /dev/null and b/project/aitoearn-wxplat/app-screenshot/5. content engagement/commentfilter2.jpeg differ diff --git "a/project/aitoearn-wxplat/app-screenshot/6. content analyze(coming soon)/\346\234\252\345\221\275\345\220\215\351\241\271\347\233\256-\345\233\276\345\261\202 1 (12).jpeg" "b/project/aitoearn-wxplat/app-screenshot/6. content analyze(coming soon)/\346\234\252\345\221\275\345\220\215\351\241\271\347\233\256-\345\233\276\345\261\202 1 (12).jpeg" new file mode 100644 index 000000000..fcabe0318 Binary files /dev/null and "b/project/aitoearn-wxplat/app-screenshot/6. content analyze(coming soon)/\346\234\252\345\221\275\345\220\215\351\241\271\347\233\256-\345\233\276\345\261\202 1 (12).jpeg" differ diff --git a/project/aitoearn-wxplat/demo/kwai/index.html b/project/aitoearn-wxplat/demo/kwai/index.html new file mode 100644 index 000000000..1e5b5b308 --- /dev/null +++ b/project/aitoearn-wxplat/demo/kwai/index.html @@ -0,0 +1,18 @@ + + + + + Title + + + + + + \ No newline at end of file diff --git a/project/aitoearn-wxplat/demo/xhs/index.html b/project/aitoearn-wxplat/demo/xhs/index.html new file mode 100644 index 000000000..480732a36 --- /dev/null +++ b/project/aitoearn-wxplat/demo/xhs/index.html @@ -0,0 +1,39 @@ + + + + + + + 小红书api demo + + + + + + + + \ No newline at end of file diff --git a/project/aitoearn-wxplat/demo/xhs/signature.js b/project/aitoearn-wxplat/demo/xhs/signature.js new file mode 100644 index 000000000..345d15e9b --- /dev/null +++ b/project/aitoearn-wxplat/demo/xhs/signature.js @@ -0,0 +1,70 @@ +import axios from "axios"; +import crypto from "crypto-js" +const appKey = "red.gLvsVoksierVz0uF"; +const appSecret = "f13a2266d1e2c32a553cb7a42ea63c48"; +let cachedAccessToken = null; +let accessTokenExpiresAt = 0; // 记录 access_token 过期时间 + +// 生成小红书签名 +function generateSignature(appKey, nonce, timeStamp, appSecret) { + const params = { + appKey, + nonce, + timeStamp, + }; + const sortedParams = Object.keys(params) + .sort() + .map((key) => `${key}=${params[key]}`) + .join("&"); + const stringToSign = sortedParams + appSecret; + console.log(stringToSign); + return crypto.SHA256(stringToSign).toString(); +} + +// 获取小红书access_token +const getAccessToken = async (nonce, timestamp) => { + if (cachedAccessToken && Date.now() < accessTokenExpiresAt) { + // 如果 access_token 未过期,则直接返回缓存的 token + return cachedAccessToken; + } + + const signature = generateSignature(appKey, nonce, timestamp, appSecret); + console.log({ + app_key: appKey, + nonce: nonce, + timestamp: timestamp, + signature: signature, + }); + try { + const response = await axios.post("https://edith.xiaohongshu.com/api/sns/v1/ext/access/token", { + app_key: appKey, + nonce: nonce, + timestamp: timestamp, + signature: signature, + }, { + headers: { + "Content-Type": "application/json", + }, + }); + console.log(response.data); + const { access_token, expires_in } = response.data.data; + + // 缓存 access_token 和计算过期时间 + cachedAccessToken = access_token; + accessTokenExpiresAt = expires_in; + + return cachedAccessToken; + } catch (error) { + console.error('请求失败:', error); + throw error; // 处理错误 + } +}; + +const nonce = Math.random().toString(36).substring(2); +const timestamp = Date.now(); +const accessToken = await getAccessToken(nonce, timestamp); +const signature = generateSignature(appKey, nonce, timestamp, accessToken); +console.log("appKey:", appKey); +console.log("nonce:", nonce); +console.log("timestamp:", timestamp); +console.log("signature:", signature); diff --git a/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI.md b/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI.md new file mode 100644 index 000000000..8467c7a16 --- /dev/null +++ b/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI.md @@ -0,0 +1,9 @@ +# Bilibili 指南 + +1. bilibili:https://open.bilibili.com/company-core + - 创建应用-选择网页应用 + - 填写基础资料和应用回调域(服务的域名) + - 填写回调域名:http://localhost:8080/api/v1/channel/bilibili/callback +2. 填写配置 + - 配置文件:`aitoearn-channel/config/local.config.js` + - 填写 `bilibili` 配置 \ No newline at end of file diff --git a/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI_EN.md b/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI_EN.md new file mode 100644 index 000000000..8bcab7fc5 --- /dev/null +++ b/project/aitoearn-wxplat/other/CHANNEL_Md/BILIBILI_EN.md @@ -0,0 +1,9 @@ +# Bilibili Guide + +1. Bilibili: https://open.bilibili.com/company-core + - Create an application - Select Web Application + - Fill in basic information and application callback domain (service domain) + - Fill in callback domain: http://localhost:8080/api/v1/channel/bilibili/callback +2. Fill in configuration + - Configuration file: `aitoearn-channel/config/local.config.js` + - Fill in `bilibili` configuration \ No newline at end of file diff --git a/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT.md b/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT.md new file mode 100644 index 000000000..0b02a2148 --- /dev/null +++ b/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT.md @@ -0,0 +1,16 @@ +# 微信三方平台 指南 +1. 注意:因为微信只能填写一个回调地址,以及有域名白名单,ip地址白名单等限制,所以把微信第三方单独抽离做了一个服务,该服务同时服务多个环境 + +2. 微信三方平台申请:https://open.weixin.qq.com + - 创建应用:管理中心>第三方平台>创建第三方平台 + - 填写应用信息 + - 填写开发配置 + - 授权事件接收配置:`https://{host}/wxPlat/callback/ticket` // 用于接收票据 + - 消息与事件接收配置`https://{host}/wxPlat/callback/msg/$APPID$` // 用于接收事件消息 + - 授权发起页域名`https://{host}` // 授权页面域名 + - 配置文件 + - 配置文件:`aitoearn-wxplat/config/local.config.js` + - 配置项:`wxPlat` + 其中`authBackHost` 填写你的aitoearn-wxplat服务域名 + `msgUrlList` 转发给aitoearn-channel的消息的域名列表 + `authUrlMap` 不同环境的授权回调地址 diff --git a/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT_EN.md b/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT_EN.md new file mode 100644 index 000000000..e769f7c9d --- /dev/null +++ b/project/aitoearn-wxplat/other/CHANNEL_Md/WXPLAT_EN.md @@ -0,0 +1,16 @@ +# WeChat Third-Party Platform Guide +1. Note: Because WeChat can only fill in one callback address, and there are restrictions such as domain name whitelist and IP address whitelist, the WeChat third-party service has been separated to serve multiple environments simultaneously. + +2. WeChat third-party platform application: https://open.weixin.qq.com + - Create application: Management Center > Third-Party Platform > Create Third-Party Platform + - Fill in application information + - Fill in development configuration + - Authorization event receiving configuration: `https://{host}/wxPlat/callback/ticket` // Used to receive tickets + - Message and event receiving configuration: `https://{host}/wxPlat/callback/msg/$APPID$` // Used to receive event messages + - Authorization initiation page domain: `https://{host}` // Authorization page domain + - Configuration file + - Configuration file: `aitoearn-wxplat/config/local.config.js` + - Configuration item: `wxPlat` + `authBackHost`: Fill in your aitoearn-wxplat service domain + `msgUrlList`: Domain list for forwarding messages to aitoearn-channel + `authUrlMap`: Authorization callback addresses for different environments \ No newline at end of file diff --git a/project/aitoearn-wxplat/other/CONTRIBUTING.md b/project/aitoearn-wxplat/other/CONTRIBUTING.md new file mode 100644 index 000000000..e40fdf2f3 --- /dev/null +++ b/project/aitoearn-wxplat/other/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contribution Guide + +Thank you for your interest in the AiToEarn project! We welcome all forms of contributions, including but not limited to bug reports, feature requests, code improvements, and documentation enhancements. + +## Table of Contents + +- [Contribution Guide](#contribution-guide) + - [Table of Contents](#table-of-contents) + - [Code of Conduct](#code-of-conduct) + - [Development Environment Setup](#development-environment-setup) + - [Submitting Pull Requests](#submitting-pull-requests) + - [Reporting Bugs](#reporting-bugs) + - [Suggesting New Features](#suggesting-new-features) + - [Code Review](#code-review) + +## Code of Conduct + +Please follow our code of conduct to ensure all participants can communicate in an open and friendly environment. + +## Development Environment Setup + +1. Fork the project to your own GitHub account +2. Create a feature branch: + ```bash + git checkout -b feature/your-feature-name + ``` +3. Commit your changes: + ```bash + git add . + git commit -m "feat: add new feature" + ``` +4. Push to your forked repository: + ```bash + git push origin feature/your-feature-name + ``` +5. Create a Pull Request on GitHub + +## Reporting Bugs +1. Ensure the bug has not been reported in other issues +2. Create a new issue and include the following information: + - Title describing the bug + - Detailed description of the bug + - Steps to reproduce the bug + - Screenshots (if available) + - Other information (such as error logs, stack traces, etc.) +## Suggesting New Features +1. Ensure the feature has not been requested in other issues +2. Create a new issue and include the following information: + - Title describing the new feature + - Detailed description of the new feature + - Screenshots (if available) +## Code Review +1. Create a pull request +2. Wait for other contributors to review the code +3. Modify the code and commit the new changes +4. Push the new commits to your forked repository +5. Ensure the pull request is merged into the main repository diff --git a/project/aitoearn-wxplat/other/CONTRIBUTING_CN.md b/project/aitoearn-wxplat/other/CONTRIBUTING_CN.md new file mode 100644 index 000000000..f6fc6b3ee --- /dev/null +++ b/project/aitoearn-wxplat/other/CONTRIBUTING_CN.md @@ -0,0 +1,57 @@ +# 贡献指南 + +感谢您对 AiToEarn 项目的关注!我们欢迎任何形式的贡献,包括但不限于提交 bug 报告、功能请求、代码改进和文档完善。 + +## 目录 + +- [贡献指南](#贡献指南) + - [目录](#目录) + - [行为准则](#行为准则) + - [开发环境搭建](#开发环境搭建) + - [提交 Pull Request](#提交-pull-request) + - [报告 Bug](#报告-bug) + - [建议新功能](#建议新功能) + - [代码审查](#代码审查) + +## 行为准则 + +请遵守我们的行为准则,确保所有参与者都能在一个开放和友好的环境中进行交流。 + +## 开发环境搭建 + +1. Fork 项目到自己的 GitHub 账户 +2. 创建功能分支: + ```bash + git checkout -b feature/your-feature-name + ``` +3. 提交更改: + ```bash + git add . + git commit -m "feat: add new feature" + ``` +4. 推送到 Fork 的仓库: + ```bash + git push origin feature/your-feature-name + ``` +5. 在 GitHub 上创建 Pull Request + +## 报告 Bug +1. 确保 bug 没有被其他 issue 所报告 +2. 创建一个新 issue,并填写以下信息: + - 描述 bug 的标题 + - 描述 bug 的详细内容 + - 步骤来复现 bug + - 截图(如果有) + - 其他信息(如错误日志、堆栈跟踪等) +## 建议新功能 +1. 确保新功能没有被其他 issue 所报告 +2. 创建一个新 issue,并填写以下信息: + - 描述新功能的标题 + - 描述新功能的详细内容 + - 截图(如果有) +## 代码审查 +1. 创建一个 pull request +2. 等待其他贡献者进行代码审查 +3. 修改代码并提交新的 commit +4. 推送新的 commit 到你的 fork 的仓库 +5. 确保 pull request 被合并到主仓库 diff --git a/project/aitoearn-wxplat/other/README.md b/project/aitoearn-wxplat/other/README.md new file mode 100644 index 000000000..fe9d4227a --- /dev/null +++ b/project/aitoearn-wxplat/other/README.md @@ -0,0 +1,160 @@ + +# AiToEarn Web + +[English](README.md) | [简体中文](README_CN.md) + +## Project Introduction + +AiToEarn's WEB project is a web application that implements multi-platform content publishing. It supports the following 12 major social media platforms: + +
+ 抖音 + B站 + 快手 + 微信公众号 + YouTube + Twitter + TikTok + Facebook + Instagram + Threads + Pinterest +
+ +**Supported Platforms for Matrix Publishing:** Douyin, Xiaohongshu, Kuaishou, Bilibili, WeChat Official Accounts, TikTok, YouTube, Facebook, Instagram, Threads, Twitter, Pinterest + +Project URL: [Aitoearn](https://aitoearn.ai) + +## Directory Structure + +### Web Frontend + +**Technology Stack:** +- React +- TypeScript +- next + +**Startup Command:** +```bash +pnpm run dev +``` + +### Backend + +**Technology Stack:** +- NestJS Node.js framework +- NATS message queue +- MongoDB +- Redis +- AWS S3 +- BullMQ + +#### 1. `aitoearn-gateway` - Gateway Module +``` +├── config Configuration files for different environments +│ +├── src +│ ├── auth Authentication module +│ ├── common Common module +│ ├── core Main process source code +│ │ ├── file File module +│ │ ├── plat Third-party platform module +│ │ └── ... Others +│ ├── libs Utility modules +│ ├── transports Communication module +│ └── views Views +``` + +#### 2. `aitoearn-channel` - Channel Module +``` +├── config Configuration files for different environments (developer keys and secrets for various third-party platforms, WeChat third-party platform configured in aitoearn-wxplay project configuration file) +│ +├── src +│ ├── common Common module +│ ├── core Main process source code +│ │ ├── account Third-party platform account module +│ │ ├── dataCube Third-party platform data statistics module +│ │ ├── file File module +│ │ ├── interact Interaction module +│ │ ├── mcp MCP service module +│ │ ├── plat Third-party platform module +│ │ ├── publish Publishing module +│ │ ├── skKey skKey module +│ │ └── ... Others +│ ├── libs Utility modules +│ ├── transports Communication module +│ └── views +``` + +#### 3. `aitoearn-user` - User Module + +#### 4. `aitoearn-wxplat` - WeChat Third-Party Platform Service (Decoupled Development Environment) + +**Quick Start:** +```bash +npm run dev:local +``` +## MCP Service + +### 1. Configure Platform Accounts + +Add platform accounts on the frontend page: +Add Platform Account + +### 2. Create `skkey` Associated with Multiple Accounts + +Create skkey + +### 3. Create and Configure Workflows + +- Create workflows on the workflow platform (or import templates from the workflow folder) +- Use `skkey` in workflow parameter settings for content publishing +Workflow Publishing + +### Interface Used by Workflow Platform +`aitoearn-channel\src\core\mcp\plugin.controller.ts` + +## Advanced Settings +### Platform Application and Setup ### +1. [Bilibili](CHANNEL_Md/BILIBILI_EN.md) +1. [WeChat Third-Party Platform](CHANNEL_Md/WXPLAT_EN.md) + + + +## Roadmap +- Add more platforms +- Add more features + +## Usage + +### 1. Start Backend Service Modules + +Local startup: Create a `local.config.js` file in the config directory (copy the `dev.config.js` file and modify the configuration) + +```bash +pnpm install +pnpm run dev:local +``` + +### 2. Start Frontend Project `aitoearn-web` + +```bash +pnpm install +pnpm run dev +``` +## Contribution Guide + +Please see [Contribution Guide](CONTRIBUTING_EN.md) for information on how to contribute to the project development. + +## Contact Us + +If you have any questions, please contact us through the following ways: + +- Submit GitHub Issues +- Send email to [contact@aitoearn.ai](mailto:contact@aitoearn.ai) diff --git a/project/aitoearn-wxplat/other/README_CN.md b/project/aitoearn-wxplat/other/README_CN.md new file mode 100644 index 000000000..3e0dadba9 --- /dev/null +++ b/project/aitoearn-wxplat/other/README_CN.md @@ -0,0 +1,160 @@ + +# AiToEarn Web + +[English](README.md) | [简体中文](README_CN.md) + +## 项目介绍 + +AiToEarn 的 WEB 项目,实现多平台内容发布的 Web 端应用。支持以下 12 个主流社交媒体平台: + +
+ 抖音 + B站 + 快手 + 微信公众号 + YouTube + Twitter + TikTok + Facebook + Instagram + Threads + Pinterest +
+ +**支持平台矩阵发布:** 抖音、小红书、快手、Bilibili、微信公众号、Tiktok、Youtube、Facebook、Instagram、Threads、Twitter、Pinterest + +项目地址:[Aitoearn](https://aitoearn.ai) + +## 目录结构 + +### Web 前端 + +**技术栈:** +- React +- TypeScript +- next + +**启动命令:** +```bash +pnpm run dev +``` + +### 服务端 + +**技术栈:** +- NestJS nodejs 框架 +- NATS 消息队列 +- MongoDB +- Redis +- AWS S3 +- BullMQ + +#### 1. `aitoearn-gateway` - 网关模块 +``` +├── config 不同环境的配置文件 +│ +├── src +│ ├── auth 认证模块 +│ ├── common 公共模块 +│ ├── core 主进程源码 +│ │ ├── file 文件模块 +│ │ ├── plat 三方平台模块 +│ │ └── ... 其他 +│ ├── libs 工具模块 +│ ├── transports 通信模块 +│ └── views 视图 +``` + +#### 2. `aitoearn-channel` - 渠道模块 +``` +├── config 不同环境的配置文件(各个三方平台的开发者key和密钥,微信三方平台在aitoearn-wxplay项目配置文件配置) +│ +├── src +│ ├── common 公共模块 +│ ├── core 主进程源码 +│ │ ├── account 三方平台账号模块 +│ │ ├── dataCube 三方平台数据统计模块 +│ │ ├── file 文件模块 +│ │ ├── interact 互动模块 +│ │ ├── mcp MCP服务用模块 +│ │ ├── plat 三方平台模块 +│ │ ├── publish 发布模块 +│ │ ├── skKey skKey模块 +│ │ └── ... 其他 +│ ├── libs 工具模块 +│ ├── transports 通信模块 +│ └── views +``` + +#### 3. `aitoearn-user` - 用户模块 + +#### 4. `aitoearn-wxplat` - 微信三方平台服务(解耦开发环境) + +**快速启动:** +```bash +npm run dev:local +``` +## MCP服务 + +### 1. 配置平台账号 + +在前端页面添加平台账号: +添加平台账号 + +### 2. 创建关联多个账号的 `skkey` + +创建skkey + +### 3. 创建和配置工作流 + +- 在工作流平台创建工作流(或导入模板-workflow文件夹) +- 在工作流的参数设置中使用 `skkey` 进行内容发布 +工作流发布 + +### 工作流平台使用的接口 +`aitoearn-channel\src\core\mcp\plugin.controller.ts` + +## 高级设置 +### 平台申请和设置 ### +1. [Bilibili](CHANNEL_Md/BILIBILI.md) +1. [微信三方平台](CHANNEL_Md/WXPLAT.md) + + + +## 计划 +- 添加更多平台 +- 添加更多功能 + +## 使用方法 + +### 1. 启动后端服务模块 + +本地启动:在 config 目录下创建 `local.config.js` 文件(复制 `dev.config.js` 文件并修改配置) + +```bash +pnpm install +pnpm run dev:local +``` + +### 2. 启动前端项目 `aitoearn-web` + +```bash +pnpm install +pnpm run dev +``` +## 贡献指南 + +请查看 [贡献指南](CONTRIBUTING.md) 了解如何参与项目开发。 + +## 联系我们 + +如有任何问题,请通过以下方式联系我们: + +- 提交 GitHub Issues +- 发送邮件至 [contact@aitoearn.ai](mailto:contact@aitoearn.ai) diff --git a/project/aitoearn-wxplat/other/workflow/coze/Workflow-pubulish-draft-8083.zip b/project/aitoearn-wxplat/other/workflow/coze/Workflow-pubulish-draft-8083.zip new file mode 100644 index 000000000..6baf49fb0 Binary files /dev/null and b/project/aitoearn-wxplat/other/workflow/coze/Workflow-pubulish-draft-8083.zip differ diff --git a/project/aitoearn-wxplat/other/workflow/dify/AiToEarn.yml b/project/aitoearn-wxplat/other/workflow/dify/AiToEarn.yml new file mode 100644 index 000000000..7795236ea --- /dev/null +++ b/project/aitoearn-wxplat/other/workflow/dify/AiToEarn.yml @@ -0,0 +1,472 @@ +app: + description: '' + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: AiToEarn + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.3.1 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_size_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: http-request + id: 1753251949465-source-1753252240600-target + selected: false + source: '1753251949465' + sourceHandle: source + target: '1753252240600' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: http-request + targetType: code + id: 1753252240600--1753260717190-target + selected: false + source: '1753252240600' + sourceHandle: source + target: '1753260717190' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: iteration + id: 1753260717190-source-1753261055086-target + selected: false + source: '1753260717190' + sourceHandle: source + target: '1753261055086' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: iteration + targetType: end + id: 1753261055086--1753261099620-target + selected: false + source: '1753261055086' + sourceHandle: source + target: '1753261099620' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1753261055086' + sourceType: iteration-start + targetType: code + id: 1753261055086start-source-1753265292456-target + selected: false + source: 1753261055086start + sourceHandle: source + target: '1753265292456' + targetHandle: target + type: custom + zIndex: 1002 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1753261055086' + sourceType: code + targetType: http-request + id: 1753265292456-source-1753271866402-target + selected: false + source: '1753265292456' + sourceHandle: source + target: '1753271866402' + targetHandle: target + type: custom + zIndex: 1002 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - label: skKey + max_length: 48 + options: [] + required: true + type: text-input + variable: skKey + - label: title + max_length: 48 + options: [] + required: true + type: text-input + variable: title + - label: desc + max_length: 48 + options: [] + required: true + type: text-input + variable: desc + - label: type + max_length: 48 + options: + - video + - article + required: true + type: select + variable: type + - label: videoUrl + max_length: 200 + options: [] + required: false + type: text-input + variable: videoUrl + - label: coverUrl + max_length: 200 + options: [] + required: true + type: text-input + variable: coverUrl + - label: imgUrlList + max_length: 2000 + options: [] + required: false + type: paragraph + variable: imgUrlList + - label: publishTime + max_length: 48 + options: [] + required: true + type: text-input + variable: publishTime + - label: topics + max_length: 200 + options: [] + required: true + type: paragraph + variable: topics + height: 297 + id: '1753251949465' + position: + x: 53.902873672086 + y: 261 + positionAbsolute: + x: 53.902873672086 + y: 261 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + authorization: + config: null + type: no-auth + body: + data: [] + type: none + desc: '' + headers: sk-key:{{#1753251949465.skKey#}} + method: GET + params: '' + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 100 + selected: false + ssl_verify: true + timeout: + max_connect_timeout: 0 + max_read_timeout: 0 + max_write_timeout: 0 + title: 获取账号列表 + type: http-request + url: https://mcp.dev.aitoearn.ai/plugin/account/list + variables: [] + height: 138 + id: '1753252240600' + position: + x: 333 + y: 261 + positionAbsolute: + x: 333 + y: 261 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + code: "\nfunction main({body}) {\n return {\n accountList: JSON.parse(body).data\n\ + \ }\n}\n" + code_language: javascript + desc: '' + outputs: + accountList: + children: null + type: array[object] + selected: false + title: Prase account list + type: code + variables: + - value_selector: + - '1753252240600' + - body + value_type: string + variable: body + height: 53 + id: '1753260717190' + position: + x: 636 + y: 261 + positionAbsolute: + x: 636 + y: 261 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + desc: '' + error_handle_mode: terminated + height: 395 + is_parallel: false + iterator_input_type: array[object] + iterator_selector: + - '1753260717190' + - accountList + output_selector: + - '1753265292456' + - accountId + output_type: array[string] + parallel_nums: 10 + selected: false + start_node_id: 1753261055086start + title: 迭代 + type: iteration + width: 837 + height: 395 + id: '1753261055086' + position: + x: 47.58759541415145 + y: 689.3383964907954 + positionAbsolute: + x: 47.58759541415145 + y: 689.3383964907954 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 837 + zIndex: 1 + - data: + desc: '' + isInIteration: true + selected: false + title: '' + type: iteration-start + draggable: false + height: 48 + id: 1753261055086start + parentId: '1753261055086' + position: + x: 24 + y: 68 + positionAbsolute: + x: 71.58759541415145 + y: 757.3383964907954 + selectable: false + sourcePosition: right + targetPosition: left + type: custom-iteration-start + width: 44 + zIndex: 1002 + - data: + desc: '' + outputs: + - value_selector: [] + variable: resList + selected: false + title: 结束 + type: end + height: 53 + id: '1753261099620' + position: + x: 66.51967325118926 + y: 1262.8998713904966 + positionAbsolute: + x: 66.51967325118926 + y: 1262.8998713904966 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + - data: + code: "\nfunction main({item}) {\n return {\n accountId: item.accountId\n\ + \ }\n}\n" + code_language: javascript + desc: '' + isInIteration: true + isInLoop: false + iteration_id: '1753261055086' + outputs: + accountId: + children: null + type: string + selected: false + title: get accountId + type: code + variables: + - value_selector: + - '1753261055086' + - item + value_type: object + variable: item + height: 53 + id: '1753265292456' + parentId: '1753261055086' + position: + x: 251.58798044054197 + y: 103.49056320376275 + positionAbsolute: + x: 299.1755758546934 + y: 792.8289596945581 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 243 + zIndex: 1002 + - data: + authorization: + config: null + type: no-auth + body: + data: + - id: key-value-1562 + key: '' + type: text + value: '{"flowId":{{#sys.workflow_run_id#}},"accountId":{{#1753265292456.accountId#}},"type":{{#1753251949465.type#}},"title":{{#1753251949465.title#}},"desc":{{#1753251949465.desc#}},"videoUrl":{{#1753251949465.videoUrl#}},"coverUrl":{{#1753251949465.coverUrl#}},"imgUrlList:{{#1753251949465.imgUrlList#}},"publishTime":{{#1753251949465.publishTime#}},"topics":{{#1753251949465.topics#}}}' + type: json + desc: '' + headers: sk-key:{{#1753251949465.skKey#}} + isInIteration: true + isInLoop: false + iteration_id: '1753261055086' + method: post + params: '' + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 100 + selected: true + ssl_verify: true + timeout: + max_connect_timeout: 0 + max_read_timeout: 0 + max_write_timeout: 0 + title: Publish + type: http-request + url: https://mcp.dev.aitoearn.cn/plugin/publish/create + variables: [] + height: 138 + id: '1753271866402' + parentId: '1753261055086' + position: + x: 577.476406804605 + y: 88.92520097208643 + positionAbsolute: + x: 625.0640022187564 + y: 778.2635974628818 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 243 + zIndex: 1002 + - data: + author: agent@aiearn.ai + desc: '' + height: 240 + selected: false + showAuthor: true + text: '{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"imgUrlList + --- Example :“http://www.xxx.com/xxx/1.png,http://www.xx.com/xx/2.png“","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""},{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"topics + --- Example: \"beauty makeup,Sports\"","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1,"textFormat":0,"textStyle":""}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}' + theme: blue + title: '' + type: '' + width: 286 + height: 240 + id: '1753337888305' + position: + x: -264.8939416724749 + y: 360.0832884811733 + positionAbsolute: + x: -264.8939416724749 + y: 360.0832884811733 + selected: false + sourcePosition: right + targetPosition: left + type: custom-note + width: 286 + viewport: + x: 468.0160911666835 + y: -37.194002857965074 + zoom: 0.7284416135218681 diff --git a/project/aitoearn-wxplat/other/workflow/img/account.jpeg b/project/aitoearn-wxplat/other/workflow/img/account.jpeg new file mode 100644 index 000000000..5f70bdc98 Binary files /dev/null and b/project/aitoearn-wxplat/other/workflow/img/account.jpeg differ diff --git a/project/aitoearn-wxplat/other/workflow/img/fl.jpeg b/project/aitoearn-wxplat/other/workflow/img/fl.jpeg new file mode 100644 index 000000000..feccd8e9d Binary files /dev/null and b/project/aitoearn-wxplat/other/workflow/img/fl.jpeg differ diff --git a/project/aitoearn-wxplat/other/workflow/img/skkey.jpg b/project/aitoearn-wxplat/other/workflow/img/skkey.jpg new file mode 100644 index 000000000..0aaa7ea1b Binary files /dev/null and b/project/aitoearn-wxplat/other/workflow/img/skkey.jpg differ diff --git a/project/aitoearn-wxplat/other/workflow/n8n/publish.json b/project/aitoearn-wxplat/other/workflow/n8n/publish.json new file mode 100644 index 000000000..8550a46f5 --- /dev/null +++ b/project/aitoearn-wxplat/other/workflow/n8n/publish.json @@ -0,0 +1,226 @@ +{ + "name": "publish", + "nodes": [ + { + "parameters": { + "formTitle": "publish", + "formFields": { + "values": [ + { + "fieldLabel": "skKey", + "requiredField": true + }, + { + "fieldLabel": "title", + "requiredField": true + }, + { + "fieldLabel": "desc", + "requiredField": true + }, + { + "fieldLabel": "type", + "fieldType": "dropdown", + "fieldOptions": { + "values": [ + { + "option": "video" + }, + { + "option": "article" + } + ] + }, + "requiredField": true + }, + { + "fieldLabel": "coverUrl", + "requiredField": true + }, + { + "fieldLabel": "videoUrl" + }, + { + "fieldLabel": "imgUrlList" + }, + { + "fieldLabel": "publishTime", + "fieldType": "date", + "requiredField": true + }, + { + "fieldLabel": "topics" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.formTrigger", + "typeVersion": 2.2, + "position": [ + -464, + -16 + ], + "id": "b5fbc2da-dab0-4a87-b576-23fe4cd4a26b", + "name": "On form submission", + "webhookId": "e2f4d383-664a-4dc6-b567-2be2a2589077" + }, + { + "parameters": { + "fieldToSplitOut": "data", + "options": {} + }, + "type": "n8n-nodes-base.splitOut", + "typeVersion": 1, + "position": [ + -48, + -16 + ], + "id": "0a5ea742-25e7-44fa-9f39-ae0204fdaa12", + "name": "Split Out" + }, + { + "parameters": { + "url": "https://mcp.aitoearn.cn/plugin/account/list", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "sk-key", + "value": "={{ $json.skKey }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + -256, + -16 + ], + "id": "f3100c99-ea4d-4ce5-85e4-6758511cb96d", + "name": "get acount list" + }, + { + "parameters": { + "method": "POST", + "url": "https://mcp.aitoearn.cn/plugin/publish/create", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "sk-key", + "value": "={{ $('On form submission').item.json.skKey }}" + } + ] + }, + "sendBody": true, + "bodyParameters": { + "parameters": [ + { + "name": "title", + "value": "={{ $('On form submission').item.json.title }}" + }, + { + "name": "desc", + "value": "={{ $('On form submission').item.json.desc }}" + }, + { + "name": "accountId", + "value": "={{ $json.accountId }}" + }, + { + "name": "type", + "value": "={{ $('On form submission').item.json.type }}" + }, + { + "name": "coverUrl", + "value": "={{ $('On form submission').item.json.coverUrl }}" + }, + { + "name": "videoUrl", + "value": "={{ $('On form submission').item.json.videoUrl }}" + }, + { + "name": "imgUrlList", + "value": "={{ $('On form submission').item.json.imgUrlList }}" + }, + { + "name": "publishTime", + "value": "={{ $('On form submission').item.json.submittedAt }}" + }, + { + "name": "topics", + "value": "={{ $('On form submission').item.json.topics }}" + }, + { + "name": "flowId", + "value": "={{ $workflow.id }}" + } + ] + }, + "options": {} + }, + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 160, + -16 + ], + "id": "0e3f1d94-7822-4c90-85e8-02784709e855", + "name": "publish" + } + ], + "pinData": {}, + "connections": { + "On form submission": { + "main": [ + [ + { + "node": "get acount list", + "type": "main", + "index": 0 + } + ] + ] + }, + "Split Out": { + "main": [ + [ + { + "node": "publish", + "type": "main", + "index": 0 + } + ] + ] + }, + "get acount list": { + "main": [ + [ + { + "node": "Split Out", + "type": "main", + "index": 0 + } + ] + ] + }, + "publish": { + "main": [ + [] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "versionId": "11829de1-0a34-4aa0-a39e-de808709ca2f", + "meta": { + "instanceId": "9376d2247e486793adc31b3d8a30547dab00f241f637b85644b85eba587538ad" + }, + "id": "UbMSJheX1Rrh2ibe", + "tags": [] +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/presentation/4o 2.jpeg b/project/aitoearn-wxplat/presentation/4o 2.jpeg new file mode 100644 index 000000000..f7f913150 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/4o 2.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/4o.jpeg b/project/aitoearn-wxplat/presentation/4o.jpeg new file mode 100644 index 000000000..f7f913150 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/4o.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/calendar.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/calendar.jpeg new file mode 100644 index 000000000..b964f72a1 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/calendar.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/support_channels.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/support_channels.jpeg new file mode 100644 index 000000000..42cc29115 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/1. content publish/support_channels.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot.jpg b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot.jpg new file mode 100644 index 000000000..3520733b6 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot.jpg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot2.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot2.jpeg new file mode 100644 index 000000000..e9b948662 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot2.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot3.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot3.jpeg new file mode 100644 index 000000000..4ad538a71 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot3.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot4.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot4.jpeg new file mode 100644 index 000000000..8ca16e49a Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/2. content hotspot/hotspot4.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch.gif b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch.gif new file mode 100644 index 000000000..65939cf0b Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch.gif differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch0.mp4 b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch0.mp4 new file mode 100644 index 000000000..634368190 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch0.mp4 differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch1.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch1.jpeg new file mode 100644 index 000000000..6539f4b5d Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch1.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch2.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch2.jpeg new file mode 100644 index 000000000..360e6195f Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch2.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch4.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch4.jpeg new file mode 100644 index 000000000..1e7406850 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/3. content search/contentsearch4.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter.jpeg new file mode 100644 index 000000000..3d6307ed5 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter.jpeg differ diff --git "a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" new file mode 100644 index 000000000..e249ba330 Binary files /dev/null and "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2-\345\260\201\351\235\242.jpg" differ diff --git "a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2.mp4" "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2.mp4" new file mode 100644 index 000000000..92f9b44bd Binary files /dev/null and "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentfilter2\350\213\261\346\226\207/commentfilter2.mp4" differ diff --git "a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" new file mode 100644 index 000000000..e249ba330 Binary files /dev/null and "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter-\345\260\201\351\235\242.jpg" differ diff --git "a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter.mp4" "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter.mp4" new file mode 100644 index 000000000..fe02c49a6 Binary files /dev/null and "b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/comments filter\344\270\255\346\226\207/comments filter.mp4" differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentsearch.gif b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentsearch.gif new file mode 100644 index 000000000..2c954ad58 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/commentsearch.gif differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter.mp4 b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter.mp4 new file mode 100644 index 000000000..15c5ac37e Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter.mp4 differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter1.mp4 b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter1.mp4 new file mode 100644 index 000000000..075f5d369 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/4. comments search/untitled folder/commentfilter1.mp4 differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/5. content engagement/commentfilter2.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/5. content engagement/commentfilter2.jpeg new file mode 100644 index 000000000..b28c546a8 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/5. content engagement/commentfilter2.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/app-screenshot/6. content analyze(coming soon)/test.jpeg b/project/aitoearn-wxplat/presentation/app-screenshot/6. content analyze(coming soon)/test.jpeg new file mode 100644 index 000000000..fcabe0318 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/app-screenshot/6. content analyze(coming soon)/test.jpeg differ diff --git a/project/aitoearn-wxplat/presentation/comment_search 2.png b/project/aitoearn-wxplat/presentation/comment_search 2.png new file mode 100644 index 000000000..e29eb39b8 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/comment_search 2.png differ diff --git a/project/aitoearn-wxplat/presentation/comment_search.png b/project/aitoearn-wxplat/presentation/comment_search.png new file mode 100644 index 000000000..e29eb39b8 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/comment_search.png differ diff --git a/project/aitoearn-wxplat/presentation/data_center 2.png b/project/aitoearn-wxplat/presentation/data_center 2.png new file mode 100644 index 000000000..ddf91c2f5 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/data_center 2.png differ diff --git a/project/aitoearn-wxplat/presentation/data_center.png b/project/aitoearn-wxplat/presentation/data_center.png new file mode 100644 index 000000000..ddf91c2f5 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/data_center.png differ diff --git a/project/aitoearn-wxplat/presentation/post 2.png b/project/aitoearn-wxplat/presentation/post 2.png new file mode 100644 index 000000000..87cc47691 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/post 2.png differ diff --git a/project/aitoearn-wxplat/presentation/post.png b/project/aitoearn-wxplat/presentation/post.png new file mode 100644 index 000000000..87cc47691 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/post.png differ diff --git a/project/aitoearn-wxplat/presentation/tool_rank 2.png b/project/aitoearn-wxplat/presentation/tool_rank 2.png new file mode 100644 index 000000000..422fb9d25 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/tool_rank 2.png differ diff --git a/project/aitoearn-wxplat/presentation/tool_rank.png b/project/aitoearn-wxplat/presentation/tool_rank.png new file mode 100644 index 000000000..422fb9d25 Binary files /dev/null and b/project/aitoearn-wxplat/presentation/tool_rank.png differ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.env b/project/aitoearn-wxplat/project/aitoearn-electron/.env new file mode 100644 index 000000000..9064cb767 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.env @@ -0,0 +1,8 @@ +VITE_APP_HOT_URL = 'https://att-contents.yikart.cn/api' +VITE_APP_FILE_HOST = 'https://ai-to-earn.oss-cn-beijing.aliyuncs.com/' + +# 正式 +VITE_APP_URL = 'https://api.aitoearn.cn/api' + +# 调试 +# VITE_APP_URL = 'http://127.0.0.1:3000/api' diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.eslintcache b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintcache new file mode 100644 index 000000000..9dec97ddb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintcache @@ -0,0 +1 @@ +[{"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\index.ts":"1","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\1707791500-InitialMigration.ts":"2","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\1707801786-AddAccountStatus.ts":"3","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\index.ts":"4","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\account.ts":"5","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\pubRecord.ts":"6","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\temp.ts":"7","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\user.ts":"8","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\video.ts":"9","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\workData.ts":"10","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\electron-env.d.ts":"11","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\event.ts":"12","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\log.ts":"13","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\schedule.ts":"14","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\store.ts":"15","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\table.ts":"16","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\browserWindow.d.ts":"17","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\BrowserWindowItem.ts":"18","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\index.ts":"19","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\controller.ts":"20","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\module.ts":"21","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\service.ts":"22","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\index.ts":"23","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\types\\index.ts":"24","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\app.ts":"25","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\controller.ts":"26","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\module.ts":"27","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\service.ts":"28","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\comment.ts":"29","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\controller.ts":"30","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\container.ts":"31","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\decorators.ts":"32","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\metadata.ts":"33","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\index.ts":"34","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\index.ts":"35","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\module.ts":"36","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\plat.type.ts":"37","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\PlatformBase.ts":"38","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\douyin\\index.ts":"39","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\Kwai\\index.ts":"40","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\wxSph\\index.ts":"41","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\xhs\\index.ts":"42","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemBase.ts":"43","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemVideo.ts":"44","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\controller.ts":"45","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\module.ts":"46","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\service.ts":"47","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\comment.ts":"48","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\controller.ts":"49","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\service.ts":"50","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\service.ts":"51","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\splash.ts":"52","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\controller.ts":"53","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\module.ts":"54","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\service.ts":"55","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\controller.ts":"56","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\module.ts":"57","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\service.ts":"58","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\update.ts":"59","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\comment.ts":"60","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\controller.ts":"61","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\module.ts":"62","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\service.ts":"63","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\views.ts":"64","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\coomont.ts":"65","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\douyin.type.ts":"66","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\index.ts":"67","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\index.ts":"68","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\kwai.type.ts":"69","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\requestNet.ts":"70","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\shipinhao\\index.ts":"71","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\shipinhao\\wxShp.type.ts":"72","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\utils\\index.ts":"73","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\xiaohongshu\\index.ts":"74","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\xiaohongshu\\xiaohongshu.type.ts":"75","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\preload\\index.ts":"76","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\tray\\systemTray.ts":"77","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\common.ts":"78","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\ffmpeg\\index.ts":"79","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\ffmpeg\\video.ts":"80","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\file.ts":"81","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\index.ts":"82","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\time.ts":"83","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\windowOperate.ts":"84","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\douyin.ts":"85","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\finance.ts":"86","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\platform.ts":"87","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\request.ts":"88","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\task.ts":"89","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\tools.ts":"90","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\finance.ts":"91","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\hotTopic.ts":"92","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\index.ts":"93","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\task.ts":"94","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\topic.ts":"95","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\user-t.ts":"96","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\userWalletAccount.ts":"97","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\viralTitles.ts":"98","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\user.ts":"99","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\App.tsx":"100","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Choose\\ImgChoose.tsx":"101","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Choose\\VideoChoose.tsx":"102","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\GetCode.tsx":"103","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\index.tsx":"104","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\Modal\\index.tsx":"105","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\Progress\\index.tsx":"106","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\WebView\\index.tsx":"107","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\config\\index.ts":"108","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\global\\table.ts":"109","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\hooks\\useCssVariables.ts":"110","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\account.ts":"111","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\app.ts":"112","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\publish.ts":"113","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\receiveMsg.ts":"114","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\tools.ts":"115","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\view.ts":"116","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\LayoutBody.tsx":"117","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\Navigation\\index.tsx":"118","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\SysMenu\\index.tsx":"119","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\main.tsx":"120","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\router\\index.tsx":"121","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\user.ts":"122","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\xiaohongshu.ts":"123","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\type\\electron-updater.d.ts":"124","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\clone.ts":"125","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\createPersistStore.ts":"126","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\index.ts":"127","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\regulars.ts":"128","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\storage.ts":"129","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\StroeEnum.ts":"130","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\comment.ts":"131","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\components\\AddAccountModal.tsx":"132","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\index.tsx":"133","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\components\\addWalletAccount.tsx":"134","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\userWalletAccount.tsx":"135","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\userWalletRecord.tsx":"136","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\LoginCore.tsx":"137","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\PhoneLogin.tsx":"138","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\qrcodeLogin.tsx":"139","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\index.tsx":"140","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\page.tsx":"141","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\pubRecord\\page.tsx":"142","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\textPage\\page.tsx":"143","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\comment.ts":"144","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\CommonPubSetting.tsx":"145","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\NoChoosePage.tsx":"146","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoChooseItem.tsx":"147","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoCoverSeting.tsx":"148","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_DouYin.tsx":"149","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_KWAI.tsx":"150","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_WxSph.tsx":"151","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_XSH.tsx":"152","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\LocationSelect.tsx":"153","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\TopicSelect.tsx":"154","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\useDebounceFetcher.ts":"155","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\UserSelect.tsx":"156","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\VideoPubSetModalCommon.tsx":"157","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\VideoPubSetModalVideo.tsx":"158","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\VideoPubSetModal.tsx":"159","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\videoPubSetModal.type.ts":"160","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\page.tsx":"161","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\useVideoPageStore.ts":"162","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\videoPage.d.ts":"163","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\comment.ts":"164","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\ChooseAccountModule\\ChooseAccountModule.tsx":"165","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\ChooseAccountModule\\components\\PlatChoose.tsx":"166","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\page.tsx":"167","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\statistics\\comment.ts":"168","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\carTask.tsx":"169","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\comment.ts":"170","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\carInfo.tsx":"171","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\mineInfo.tsx":"172","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\popInfo.tsx":"173","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\videoInfo.tsx":"174","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\withdraw.tsx":"175","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\mineTask.tsx":"176","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\popTask.tsx":"177","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\videoTask.tsx":"178","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\trending\\hotTopic.tsx":"179","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\trending\\index.tsx":"180","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\vite-env.d.ts":"181","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\controller.ts":"182","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\module.ts":"183","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\service.ts":"184","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\common.douyin.ts":"185","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\operate.ts":"186","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\operate.ts":"187","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\reply.ts":"188","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\components\\AccountSidebar\\AccountSidebar.tsx":"189","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\index.tsx":"190","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\autoRun.ts":"191","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\autoRunRecord.ts":"192","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\tools.ts":"193","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\comment.ts":"194","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\controller.ts":"195","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\module.ts":"196","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\service.ts":"197","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\sign\\KwaiSign.ts":"198","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\ErrorBoundary\\ErrorBoundary.tsx":"199","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\VideoPlayer\\index.tsx":"200","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\autoRun.ts":"201","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\useImagePageStore.ts":"202","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\PubAccountDetModule\\PubAccountDetModule.tsx":"203","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\PubProgressModule\\PubProgressModule.tsx":"204","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\SupportPlat\\SupportPlat.tsx":"205","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\replyComment.tsx":"206","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\replyWorks.tsx":"207","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\interact.tsx":"208","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\tools.ts":"209","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\account.ts":"210","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\pubStroe.ts":"211","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\AICreateTitle\\AICreateTitle.tsx":"212","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\AICreateTitle\\useAICreateTitle.ts":"213","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\addAutoRun.tsx":"214","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\notice.ts":"215","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Inform\\index.tsx":"216","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\replyother.ts":"217","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageLeftSetting\\ImageLeftSetting.tsx":"218","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageLeftSetting\\ImgTextImagesView.tsx":"219","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\hooks\\useImagePlatParams.ts":"220","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\ImageParamsSet_Douyin.tsx":"221","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\ImageParamsSet_XHS.tsx":"222","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageParamsSet.tsx":"223","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageParamsSet.type.ts":"224","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageRightSettingCommon.tsx":"225","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ParamsSettingDetails.tsx":"226","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\ImageRightSetting.tsx":"227","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\imagePage.type.ts":"228","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonComponents.tsx":"229","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonLocationSelect.tsx":"230","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonScheduledTimeSelect.tsx":"231","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonTopicSelect.tsx":"232","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonUserSelect.tsx":"233","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\DouyinCommonComponents.tsx":"234","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\commentList.tsx":"235","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\addAutoRun.tsx":"236","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\replyComment.tsx":"237","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\replyWorks.tsx":"238","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\interact.tsx":"239","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\imgText.ts":"240","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemImgText.ts":"241","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\imgText\\controller.ts":"242","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\imgText\\service.ts":"243","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\CronSchedule.tsx":"244","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\commont.ts":"245","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\hooks\\useVideoPubSetModal.tsx":"246","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\hooks\\usePubParamsVerify.tsx":"247","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\autoRun.tsx":"248","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\autoRunRecord.tsx":"249","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\replyCommentRecord.ts":"250","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\pubRecord\\components\\PubRecordDetails.tsx":"251","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\oneKeyReply.tsx":"252","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\cache.ts":"253","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\interactionRecord.ts":"254","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\tracing.ts":"255","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\cacheData.ts":"256","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\controller.ts":"257","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\module.ts":"258","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\service.ts":"259","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\Kwai\\KwaiPubListener.ts":"260","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\cacheData.ts":"261","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\controller.ts":"262","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\module.ts":"263","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\service.ts":"264","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\cfg.ts":"265","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\feedback.ts":"266","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\tracing.ts":"267","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\platform.type.ts":"268","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\UploadImages\\index.tsx":"269","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\WindowControlButtons\\WindowControlButtons.tsx":"270","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\BellMessage\\index.tsx":"271","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\UpdateLog\\index.tsx":"272","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\bellMessageStroe.ts":"273","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiRanking\\echarts-weekPie.ts":"274","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiRanking\\index.tsx":"275","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiToolWebview\\index.tsx":"276","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\components\\CycleSelects\\index.tsx":"277","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\components\\RankingTags\\index.tsx":"278","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\index.tsx":"279","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\finance.tsx":"280","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\components\\addAutoRun.tsx":"281","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\components\\autoRunRecord.tsx":"282","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\components\\oneKeyInteraction.tsx":"283","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\index.tsx":"284","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\replyother.tsx":"285","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\statistics\\statistics.tsx":"286","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\articleTask.tsx":"287","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\articleInfo.tsx":"288","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\interactionTask.tsx":"289","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\task copy.tsx":"290","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\task.tsx":"291","E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\test\\index.tsx":"292"},{"size":4988,"mtime":1745077499882,"results":"293","hashOfConfig":"294"},{"size":6474,"mtime":1740566590253,"results":"295","hashOfConfig":"294"},{"size":503,"mtime":1740566590253,"results":"296","hashOfConfig":"294"},{"size":160,"mtime":1740566590254,"results":"297","hashOfConfig":"294"},{"size":2839,"mtime":1740570505203,"results":"298","hashOfConfig":"294"},{"size":2029,"mtime":1745411766249,"results":"299","hashOfConfig":"294"},{"size":573,"mtime":1740566590254,"results":"300","hashOfConfig":"294"},{"size":820,"mtime":1740645389956,"results":"301","hashOfConfig":"294"},{"size":602,"mtime":1745077499883,"results":"302","hashOfConfig":"294"},{"size":5589,"mtime":1745411766212,"results":"303","hashOfConfig":"294"},{"size":601,"mtime":1740566590255,"results":"304","hashOfConfig":"294"},{"size":239,"mtime":1745077499883,"results":"305","hashOfConfig":"294"},{"size":2113,"mtime":1745289598268,"results":"306","hashOfConfig":"294"},{"size":498,"mtime":1740566590255,"results":"307","hashOfConfig":"294"},{"size":486,"mtime":1740566590255,"results":"308","hashOfConfig":"294"},{"size":1012,"mtime":1745411766255,"results":"309","hashOfConfig":"294"},{"size":329,"mtime":1740566590256,"results":"310","hashOfConfig":"294"},{"size":1501,"mtime":1745077499884,"results":"311","hashOfConfig":"294"},{"size":932,"mtime":1741787078177,"results":"312","hashOfConfig":"294"},{"size":6586,"mtime":1745077499885,"results":"313","hashOfConfig":"294"},{"size":399,"mtime":1740566590256,"results":"314","hashOfConfig":"294"},{"size":5040,"mtime":1745318475288,"results":"315","hashOfConfig":"294"},{"size":1805,"mtime":1742356271827,"results":"316","hashOfConfig":"294"},{"size":289,"mtime":1740566590257,"results":"317","hashOfConfig":"294"},{"size":1192,"mtime":1745318475292,"results":"318","hashOfConfig":"294"},{"size":839,"mtime":1740566590258,"results":"319","hashOfConfig":"294"},{"size":254,"mtime":1740566590258,"results":"320","hashOfConfig":"294"},{"size":1588,"mtime":1740566590258,"results":"321","hashOfConfig":"294"},{"size":154,"mtime":1740566590258,"results":"322","hashOfConfig":"294"},{"size":539,"mtime":1745077499886,"results":"323","hashOfConfig":"294"},{"size":3836,"mtime":1740566590258,"results":"324","hashOfConfig":"294"},{"size":3587,"mtime":1745077499886,"results":"325","hashOfConfig":"294"},{"size":220,"mtime":1740566590258,"results":"326","hashOfConfig":"294"},{"size":5391,"mtime":1745325267933,"results":"327","hashOfConfig":"294"},{"size":10158,"mtime":1745318475293,"results":"328","hashOfConfig":"294"},{"size":1100,"mtime":1745411766242,"results":"329","hashOfConfig":"294"},{"size":3647,"mtime":1745077499888,"results":"330","hashOfConfig":"294"},{"size":5670,"mtime":1745077499888,"results":"331","hashOfConfig":"294"},{"size":18075,"mtime":1745077499889,"results":"332","hashOfConfig":"294"},{"size":15847,"mtime":1745411766252,"results":"333","hashOfConfig":"294"},{"size":12283,"mtime":1745077499890,"results":"334","hashOfConfig":"294"},{"size":17515,"mtime":1745077499890,"results":"335","hashOfConfig":"294"},{"size":365,"mtime":1743244938057,"results":"336","hashOfConfig":"294"},{"size":2938,"mtime":1745411766202,"results":"337","hashOfConfig":"294"},{"size":7175,"mtime":1745411766258,"results":"338","hashOfConfig":"294"},{"size":724,"mtime":1745077499891,"results":"339","hashOfConfig":"294"},{"size":2280,"mtime":1745411766261,"results":"340","hashOfConfig":"294"},{"size":0,"mtime":1745077499891,"results":"341","hashOfConfig":"294"},{"size":6162,"mtime":1745411766221,"results":"342","hashOfConfig":"294"},{"size":3560,"mtime":1745077499892,"results":"343","hashOfConfig":"294"},{"size":411,"mtime":1745077499893,"results":"344","hashOfConfig":"294"},{"size":1643,"mtime":1745077499893,"results":"345","hashOfConfig":"294"},{"size":5251,"mtime":1745077499893,"results":"346","hashOfConfig":"294"},{"size":384,"mtime":1740566590261,"results":"347","hashOfConfig":"294"},{"size":634,"mtime":1740566590261,"results":"348","hashOfConfig":"294"},{"size":1901,"mtime":1745289598269,"results":"349","hashOfConfig":"294"},{"size":405,"mtime":1740566590261,"results":"350","hashOfConfig":"294"},{"size":208,"mtime":1740566590261,"results":"351","hashOfConfig":"294"},{"size":2824,"mtime":1740566590261,"results":"352","hashOfConfig":"294"},{"size":721,"mtime":1740566590261,"results":"353","hashOfConfig":"294"},{"size":965,"mtime":1740644262905,"results":"354","hashOfConfig":"294"},{"size":384,"mtime":1740566590262,"results":"355","hashOfConfig":"294"},{"size":819,"mtime":1740566590262,"results":"356","hashOfConfig":"294"},{"size":5372,"mtime":1745077499893,"results":"357","hashOfConfig":"294"},{"size":0,"mtime":1745077499895,"results":"358","hashOfConfig":"294"},{"size":22538,"mtime":1745077499895,"results":"359","hashOfConfig":"294"},{"size":86299,"mtime":1745219014974,"results":"360","hashOfConfig":"294"},{"size":26275,"mtime":1745218715741,"results":"361","hashOfConfig":"294"},{"size":8695,"mtime":1745205192262,"results":"362","hashOfConfig":"294"},{"size":2713,"mtime":1745077499896,"results":"363","hashOfConfig":"294"},{"size":46184,"mtime":1745208691352,"results":"364","hashOfConfig":"294"},{"size":7876,"mtime":1745077499897,"results":"365","hashOfConfig":"294"},{"size":1323,"mtime":1745077499897,"results":"366","hashOfConfig":"294"},{"size":52680,"mtime":1745222218527,"results":"367","hashOfConfig":"294"},{"size":6403,"mtime":1741957238182,"results":"368","hashOfConfig":"294"},{"size":3788,"mtime":1740972429489,"results":"369","hashOfConfig":"294"},{"size":1531,"mtime":1745077499897,"results":"370","hashOfConfig":"294"},{"size":2508,"mtime":1745077499897,"results":"371","hashOfConfig":"294"},{"size":618,"mtime":1745077499897,"results":"372","hashOfConfig":"294"},{"size":1771,"mtime":1745077499897,"results":"373","hashOfConfig":"294"},{"size":9221,"mtime":1745289598270,"results":"374","hashOfConfig":"294"},{"size":1424,"mtime":1740656204260,"results":"375","hashOfConfig":"294"},{"size":455,"mtime":1745077499897,"results":"376","hashOfConfig":"294"},{"size":713,"mtime":1740566590265,"results":"377","hashOfConfig":"294"},{"size":868,"mtime":1740566593145,"results":"378","hashOfConfig":"294"},{"size":2121,"mtime":1745077499914,"results":"379","hashOfConfig":"294"},{"size":7956,"mtime":1745077499914,"results":"380","hashOfConfig":"294"},{"size":4245,"mtime":1745077499914,"results":"381","hashOfConfig":"294"},{"size":2596,"mtime":1745077499915,"results":"382","hashOfConfig":"294"},{"size":1975,"mtime":1745326980622,"results":"383","hashOfConfig":"294"},{"size":781,"mtime":1740967482027,"results":"384","hashOfConfig":"294"},{"size":653,"mtime":1740621498197,"results":"385","hashOfConfig":"294"},{"size":515,"mtime":1740566593145,"results":"386","hashOfConfig":"294"},{"size":1889,"mtime":1745077499915,"results":"387","hashOfConfig":"294"},{"size":445,"mtime":1740621498197,"results":"388","hashOfConfig":"294"},{"size":476,"mtime":1745077499916,"results":"389","hashOfConfig":"294"},{"size":807,"mtime":1745077499916,"results":"390","hashOfConfig":"294"},{"size":332,"mtime":1745077499916,"results":"391","hashOfConfig":"294"},{"size":2070,"mtime":1745077499917,"results":"392","hashOfConfig":"294"},{"size":919,"mtime":1745077499914,"results":"393","hashOfConfig":"294"},{"size":3163,"mtime":1745077499919,"results":"394","hashOfConfig":"294"},{"size":5071,"mtime":1745310493167,"results":"395","hashOfConfig":"294"},{"size":1496,"mtime":1745077499920,"results":"396","hashOfConfig":"294"},{"size":4970,"mtime":1742356271836,"results":"397","hashOfConfig":"294"},{"size":2429,"mtime":1740566593147,"results":"398","hashOfConfig":"294"},{"size":588,"mtime":1740566593147,"results":"399","hashOfConfig":"294"},{"size":3618,"mtime":1745077499921,"results":"400","hashOfConfig":"294"},{"size":334,"mtime":1740566593148,"results":"401","hashOfConfig":"294"},{"size":823,"mtime":1743161536423,"results":"402","hashOfConfig":"294"},{"size":2021,"mtime":1740566593148,"results":"403","hashOfConfig":"294"},{"size":2535,"mtime":1745077499924,"results":"404","hashOfConfig":"294"},{"size":379,"mtime":1742356271836,"results":"405","hashOfConfig":"294"},{"size":9403,"mtime":1745411766240,"results":"406","hashOfConfig":"294"},{"size":2231,"mtime":1745407643828,"results":"407","hashOfConfig":"294"},{"size":1074,"mtime":1745289598275,"results":"408","hashOfConfig":"294"},{"size":733,"mtime":1741100565308,"results":"409","hashOfConfig":"294"},{"size":1805,"mtime":1745408066289,"results":"410","hashOfConfig":"294"},{"size":2623,"mtime":1745377951049,"results":"411","hashOfConfig":"294"},{"size":3485,"mtime":1745320989936,"results":"412","hashOfConfig":"294"},{"size":1086,"mtime":1745077499926,"results":"413","hashOfConfig":"294"},{"size":5081,"mtime":1745313303770,"results":"414","hashOfConfig":"294"},{"size":2238,"mtime":1745077499927,"results":"415","hashOfConfig":"294"},{"size":548,"mtime":1740566593151,"results":"416","hashOfConfig":"294"},{"size":157,"mtime":1740566593151,"results":"417","hashOfConfig":"294"},{"size":86,"mtime":1740566593151,"results":"418","hashOfConfig":"294"},{"size":2314,"mtime":1740566593151,"results":"419","hashOfConfig":"294"},{"size":2392,"mtime":1745077499927,"results":"420","hashOfConfig":"294"},{"size":700,"mtime":1740566593152,"results":"421","hashOfConfig":"294"},{"size":671,"mtime":1741421456983,"results":"422","hashOfConfig":"294"},{"size":83,"mtime":1745077499927,"results":"423","hashOfConfig":"294"},{"size":3538,"mtime":1745077499928,"results":"424","hashOfConfig":"294"},{"size":2187,"mtime":1740566593153,"results":"425","hashOfConfig":"294"},{"size":1341,"mtime":1744715838461,"results":"426","hashOfConfig":"294"},{"size":3709,"mtime":1745077499929,"results":"427","hashOfConfig":"294"},{"size":3210,"mtime":1745077499929,"results":"428","hashOfConfig":"294"},{"size":6457,"mtime":1745077499929,"results":"429","hashOfConfig":"294"},{"size":982,"mtime":1745077499929,"results":"430","hashOfConfig":"294"},{"size":4274,"mtime":1745077499929,"results":"431","hashOfConfig":"294"},{"size":7612,"mtime":1745077499929,"results":"432","hashOfConfig":"294"},{"size":2499,"mtime":1745077499929,"results":"433","hashOfConfig":"294"},{"size":8936,"mtime":1745289192976,"results":"434","hashOfConfig":"294"},{"size":8369,"mtime":1745411766266,"results":"435","hashOfConfig":"294"},{"size":140,"mtime":1740566593155,"results":"436","hashOfConfig":"294"},{"size":251,"mtime":1741263400131,"results":"437","hashOfConfig":"294"},{"size":8820,"mtime":1745077499929,"results":"438","hashOfConfig":"294"},{"size":4077,"mtime":1745077499929,"results":"439","hashOfConfig":"294"},{"size":9525,"mtime":1745077499929,"results":"440","hashOfConfig":"294"},{"size":7381,"mtime":1741315740444,"results":"441","hashOfConfig":"294"},{"size":5809,"mtime":1745077499929,"results":"442","hashOfConfig":"294"},{"size":1381,"mtime":1745077499929,"results":"443","hashOfConfig":"294"},{"size":6606,"mtime":1745077499929,"results":"444","hashOfConfig":"294"},{"size":1508,"mtime":1745077499929,"results":"445","hashOfConfig":"294"},{"size":1491,"mtime":1745077499929,"results":"446","hashOfConfig":"294"},{"size":1747,"mtime":1745077499929,"results":"447","hashOfConfig":"294"},{"size":991,"mtime":1745077499929,"results":"448","hashOfConfig":"294"},{"size":1732,"mtime":1745077499929,"results":"449","hashOfConfig":"294"},{"size":6987,"mtime":1745077499929,"results":"450","hashOfConfig":"294"},{"size":4270,"mtime":1745077499929,"results":"451","hashOfConfig":"294"},{"size":16491,"mtime":1745411766246,"results":"452","hashOfConfig":"294"},{"size":98,"mtime":1745077499929,"results":"453","hashOfConfig":"294"},{"size":8810,"mtime":1745077499929,"results":"454","hashOfConfig":"294"},{"size":18045,"mtime":1745206943505,"results":"455","hashOfConfig":"294"},{"size":1159,"mtime":1745077499929,"results":"456","hashOfConfig":"294"},{"size":960,"mtime":1745077499929,"results":"457","hashOfConfig":"294"},{"size":3224,"mtime":1745077499929,"results":"458","hashOfConfig":"294"},{"size":13911,"mtime":1745077499929,"results":"459","hashOfConfig":"294"},{"size":1524,"mtime":1745077499947,"results":"460","hashOfConfig":"294"},{"size":604,"mtime":1741432602663,"results":"461","hashOfConfig":"294"},{"size":6702,"mtime":1745077499951,"results":"462","hashOfConfig":"294"},{"size":154,"mtime":1740967482029,"results":"463","hashOfConfig":"294"},{"size":5899,"mtime":1745077499951,"results":"464","hashOfConfig":"294"},{"size":11533,"mtime":1745077499952,"results":"465","hashOfConfig":"294"},{"size":9266,"mtime":1745077499952,"results":"466","hashOfConfig":"294"},{"size":14942,"mtime":1745077499952,"results":"467","hashOfConfig":"294"},{"size":4611,"mtime":1745077499952,"results":"468","hashOfConfig":"294"},{"size":23843,"mtime":1745077499953,"results":"469","hashOfConfig":"294"},{"size":6734,"mtime":1745077499954,"results":"470","hashOfConfig":"294"},{"size":9880,"mtime":1745077499955,"results":"471","hashOfConfig":"294"},{"size":537,"mtime":1740621498199,"results":"472","hashOfConfig":"294"},{"size":130237,"mtime":1745077499955,"results":"473","hashOfConfig":"294"},{"size":266,"mtime":1740566593163,"results":"474","hashOfConfig":"294"},{"size":9522,"mtime":1745077499892,"results":"475","hashOfConfig":"294"},{"size":556,"mtime":1745077499892,"results":"476","hashOfConfig":"294"},{"size":9330,"mtime":1745077499892,"results":"477","hashOfConfig":"294"},{"size":418,"mtime":1741781458560,"results":"478","hashOfConfig":"294"},{"size":405,"mtime":1741603138347,"results":"479","hashOfConfig":"294"},{"size":215,"mtime":1741603138347,"results":"480","hashOfConfig":"294"},{"size":3754,"mtime":1745077499924,"results":"481","hashOfConfig":"294"},{"size":7911,"mtime":1745394425550,"results":"482","hashOfConfig":"294"},{"size":8105,"mtime":1745220356265,"results":"483","hashOfConfig":"294"},{"size":1638,"mtime":1745077499882,"results":"484","hashOfConfig":"294"},{"size":1432,"mtime":1745077499882,"results":"485","hashOfConfig":"294"},{"size":1852,"mtime":1745289598268,"results":"486","hashOfConfig":"294"},{"size":2508,"mtime":1745077499885,"results":"487","hashOfConfig":"294"},{"size":4263,"mtime":1745077499885,"results":"488","hashOfConfig":"294"},{"size":428,"mtime":1742356271828,"results":"489","hashOfConfig":"294"},{"size":6595,"mtime":1745077499885,"results":"490","hashOfConfig":"294"},{"size":1587,"mtime":1742356271831,"results":"491","hashOfConfig":"294"},{"size":1391,"mtime":1742356271835,"results":"492","hashOfConfig":"294"},{"size":1090,"mtime":1745077499920,"results":"493","hashOfConfig":"294"},{"size":3043,"mtime":1745077499924,"results":"494","hashOfConfig":"294"},{"size":9355,"mtime":1745077499929,"results":"495","hashOfConfig":"294"},{"size":8490,"mtime":1742356271845,"results":"496","hashOfConfig":"294"},{"size":3097,"mtime":1745398882848,"results":"497","hashOfConfig":"294"},{"size":1458,"mtime":1745077499946,"results":"498","hashOfConfig":"294"},{"size":3242,"mtime":1745077499948,"results":"499","hashOfConfig":"294"},{"size":3129,"mtime":1745077499948,"results":"500","hashOfConfig":"294"},{"size":0,"mtime":1742356271846,"results":"501","hashOfConfig":"294"},{"size":103,"mtime":1745077499916,"results":"502","hashOfConfig":"294"},{"size":1621,"mtime":1745077499926,"results":"503","hashOfConfig":"294"},{"size":3087,"mtime":1745077499926,"results":"504","hashOfConfig":"294"},{"size":3067,"mtime":1745077499929,"results":"505","hashOfConfig":"294"},{"size":1262,"mtime":1745077499929,"results":"506","hashOfConfig":"294"},{"size":1769,"mtime":1745077499947,"results":"507","hashOfConfig":"294"},{"size":571,"mtime":1745077499884,"results":"508","hashOfConfig":"294"},{"size":1014,"mtime":1745077499920,"results":"509","hashOfConfig":"294"},{"size":5259,"mtime":1745077499925,"results":"510","hashOfConfig":"294"},{"size":3593,"mtime":1745077499929,"results":"511","hashOfConfig":"294"},{"size":3392,"mtime":1745077499929,"results":"512","hashOfConfig":"294"},{"size":594,"mtime":1745077499929,"results":"513","hashOfConfig":"294"},{"size":3892,"mtime":1745077499929,"results":"514","hashOfConfig":"294"},{"size":959,"mtime":1745077499929,"results":"515","hashOfConfig":"294"},{"size":6467,"mtime":1745077499929,"results":"516","hashOfConfig":"294"},{"size":49,"mtime":1745077499929,"results":"517","hashOfConfig":"294"},{"size":10446,"mtime":1745077499929,"results":"518","hashOfConfig":"294"},{"size":5710,"mtime":1745077499929,"results":"519","hashOfConfig":"294"},{"size":3383,"mtime":1745077499929,"results":"520","hashOfConfig":"294"},{"size":205,"mtime":1745077499929,"results":"521","hashOfConfig":"294"},{"size":5062,"mtime":1745077499929,"results":"522","hashOfConfig":"294"},{"size":2831,"mtime":1745077499929,"results":"523","hashOfConfig":"294"},{"size":3349,"mtime":1745077499945,"results":"524","hashOfConfig":"294"},{"size":2093,"mtime":1745077499945,"results":"525","hashOfConfig":"294"},{"size":3549,"mtime":1745077499945,"results":"526","hashOfConfig":"294"},{"size":12344,"mtime":1745077499945,"results":"527","hashOfConfig":"294"},{"size":4110,"mtime":1745077499947,"results":"528","hashOfConfig":"294"},{"size":2060,"mtime":1745077499949,"results":"529","hashOfConfig":"294"},{"size":3371,"mtime":1745077499949,"results":"530","hashOfConfig":"294"},{"size":3057,"mtime":1745077499949,"results":"531","hashOfConfig":"294"},{"size":0,"mtime":1745077499949,"results":"532","hashOfConfig":"294"},{"size":429,"mtime":1745077499882,"results":"533","hashOfConfig":"294"},{"size":2251,"mtime":1745411766231,"results":"534","hashOfConfig":"294"},{"size":2634,"mtime":1745411766227,"results":"535","hashOfConfig":"294"},{"size":1009,"mtime":1745077499891,"results":"536","hashOfConfig":"294"},{"size":3903,"mtime":1745077499919,"results":"537","hashOfConfig":"294"},{"size":701,"mtime":1745077499926,"results":"538","hashOfConfig":"294"},{"size":792,"mtime":1745077499929,"results":"539","hashOfConfig":"294"},{"size":6940,"mtime":1745310437715,"results":"540","hashOfConfig":"294"},{"size":5323,"mtime":1745077499947,"results":"541","hashOfConfig":"294"},{"size":3030,"mtime":1745077499947,"results":"542","hashOfConfig":"294"},{"size":1172,"mtime":1745077499883,"results":"543","hashOfConfig":"294"},{"size":9395,"mtime":1745394059170,"results":"544","hashOfConfig":"294"},{"size":3420,"mtime":1745077499948,"results":"545","hashOfConfig":"294"},{"size":1131,"mtime":1745077499883,"results":"546","hashOfConfig":"294"},{"size":1444,"mtime":1745077499882,"results":"547","hashOfConfig":"294"},{"size":3167,"mtime":1745312301761,"results":"548","hashOfConfig":"294"},{"size":1720,"mtime":1745077499887,"results":"549","hashOfConfig":"294"},{"size":4909,"mtime":1745077499887,"results":"550","hashOfConfig":"294"},{"size":586,"mtime":1745077499887,"results":"551","hashOfConfig":"294"},{"size":11843,"mtime":1745077499888},{"size":3992,"mtime":1745411766235},{"size":1628,"mtime":1745077499892,"results":"552","hashOfConfig":"294"},{"size":958,"mtime":1745318475293,"results":"553","hashOfConfig":"294"},{"size":259,"mtime":1745312301762,"results":"554","hashOfConfig":"294"},{"size":99,"mtime":1745312301762,"results":"555","hashOfConfig":"294"},{"size":353,"mtime":1745077499914,"results":"556","hashOfConfig":"294"},{"size":753,"mtime":1745327101772,"results":"557","hashOfConfig":"294"},{"size":2630,"mtime":1745220356264,"results":"558","hashOfConfig":"294"},{"size":1295,"mtime":1745077499915,"results":"559","hashOfConfig":"294"},{"size":4301,"mtime":1745328505633,"results":"560","hashOfConfig":"294"},{"size":1653,"mtime":1745077499921,"results":"561","hashOfConfig":"294"},{"size":4996,"mtime":1745399241698,"results":"562","hashOfConfig":"294"},{"size":3638,"mtime":1745327792024,"results":"563","hashOfConfig":"294"},{"size":2054,"mtime":1745411766218},{"size":1395,"mtime":1745077499929,"results":"564","hashOfConfig":"294"},{"size":10023,"mtime":1745077499929,"results":"565","hashOfConfig":"294"},{"size":751,"mtime":1745077499929,"results":"566","hashOfConfig":"294"},{"size":5405,"mtime":1745077499929,"results":"567","hashOfConfig":"294"},{"size":1327,"mtime":1745077499929,"results":"568","hashOfConfig":"294"},{"size":2159,"mtime":1745313261077,"results":"569","hashOfConfig":"294"},{"size":2625,"mtime":1745077499929},{"size":1769,"mtime":1745077499929,"results":"570","hashOfConfig":"294"},{"size":3030,"mtime":1745077499929,"results":"571","hashOfConfig":"294"},{"size":3611,"mtime":1745077499929},{"size":6128,"mtime":1745077499929,"results":"572","hashOfConfig":"294"},{"size":77257,"mtime":1745077499950},{"size":31062,"mtime":1745289598276},{"size":15357,"mtime":1745292282937},{"size":9997,"mtime":1745077499951},{"size":15992,"mtime":1745292282934},{"size":3842,"mtime":1745077499954},{"size":28417,"mtime":1745292282928},{"size":1355,"mtime":1745289598278,"results":"573","hashOfConfig":"294"},{"filePath":"574","messages":"575","suppressedMessages":"576","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"pn5eqz",{"filePath":"577","messages":"578","suppressedMessages":"579","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"580","messages":"581","suppressedMessages":"582","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"583","messages":"584","suppressedMessages":"585","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"586","messages":"587","suppressedMessages":"588","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"589","messages":"590","suppressedMessages":"591","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"592","messages":"593","suppressedMessages":"594","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"595","messages":"596","suppressedMessages":"597","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"598","messages":"599","suppressedMessages":"600","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"601","messages":"602","suppressedMessages":"603","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"604","messages":"605","suppressedMessages":"606","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"607","messages":"608","suppressedMessages":"609","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"610","messages":"611","suppressedMessages":"612","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"613","messages":"614","suppressedMessages":"615","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"616","messages":"617","suppressedMessages":"618","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"619","messages":"620","suppressedMessages":"621","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"622","messages":"623","suppressedMessages":"624","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"625","messages":"626","suppressedMessages":"627","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"628","messages":"629","suppressedMessages":"630","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"631","messages":"632","suppressedMessages":"633","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"634","messages":"635","suppressedMessages":"636","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"637","messages":"638","suppressedMessages":"639","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"640","messages":"641","suppressedMessages":"642","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"643","messages":"644","suppressedMessages":"645","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"646","messages":"647","suppressedMessages":"648","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"649","messages":"650","suppressedMessages":"651","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"652","messages":"653","suppressedMessages":"654","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"655","messages":"656","suppressedMessages":"657","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"658","messages":"659","suppressedMessages":"660","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"661","messages":"662","suppressedMessages":"663","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"664","messages":"665","suppressedMessages":"666","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"667","messages":"668","suppressedMessages":"669","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"670","messages":"671","suppressedMessages":"672","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"673","messages":"674","suppressedMessages":"675","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"676","messages":"677","suppressedMessages":"678","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"679","messages":"680","suppressedMessages":"681","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"682","messages":"683","suppressedMessages":"684","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"685","messages":"686","suppressedMessages":"687","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"688","messages":"689","suppressedMessages":"690","errorCount":0,"fatalErrorCount":0,"warningCount":7,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"691","messages":"692","suppressedMessages":"693","errorCount":0,"fatalErrorCount":0,"warningCount":28,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"694","messages":"695","suppressedMessages":"696","errorCount":0,"fatalErrorCount":0,"warningCount":28,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"697","messages":"698","suppressedMessages":"699","errorCount":0,"fatalErrorCount":0,"warningCount":6,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"700","messages":"701","suppressedMessages":"702","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"703","messages":"704","suppressedMessages":"705","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"706","messages":"707","suppressedMessages":"708","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"709","messages":"710","suppressedMessages":"711","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"712","messages":"713","suppressedMessages":"714","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"715","messages":"716","suppressedMessages":"717","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"718","messages":"719","suppressedMessages":"720","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"721","messages":"722","suppressedMessages":"723","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"724","messages":"725","suppressedMessages":"726","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"727","messages":"728","suppressedMessages":"729","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"730","messages":"731","suppressedMessages":"732","errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"733","messages":"734","suppressedMessages":"735","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"736","messages":"737","suppressedMessages":"738","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"739","messages":"740","suppressedMessages":"741","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"742","messages":"743","suppressedMessages":"744","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"745","messages":"746","suppressedMessages":"747","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"748","messages":"749","suppressedMessages":"750","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"751","messages":"752","suppressedMessages":"753","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"754","messages":"755","suppressedMessages":"756","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"757","messages":"758","suppressedMessages":"759","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"760","messages":"761","suppressedMessages":"762","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"763","messages":"764","suppressedMessages":"765","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"766","messages":"767","suppressedMessages":"768","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"769","messages":"770","suppressedMessages":"771","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"772","messages":"773","suppressedMessages":"774","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"775","messages":"776","suppressedMessages":"777","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"778","messages":"779","suppressedMessages":"780","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"781","messages":"782","suppressedMessages":"783","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"784","messages":"785","suppressedMessages":"786","errorCount":0,"fatalErrorCount":0,"warningCount":5,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"787","messages":"788","suppressedMessages":"789","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"790","messages":"791","suppressedMessages":"792","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"793","messages":"794","suppressedMessages":"795","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"796","messages":"797","suppressedMessages":"798","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"799","messages":"800","suppressedMessages":"801","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"802","messages":"803","suppressedMessages":"804","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"805","messages":"806","suppressedMessages":"807","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"808","messages":"809","suppressedMessages":"810","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"811","messages":"812","suppressedMessages":"813","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"814","messages":"815","suppressedMessages":"816","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"817","messages":"818","suppressedMessages":"819","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"820","messages":"821","suppressedMessages":"822","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"823","messages":"824","suppressedMessages":"825","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"826","messages":"827","suppressedMessages":"828","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"829","messages":"830","suppressedMessages":"831","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"832","messages":"833","suppressedMessages":"834","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"835","messages":"836","suppressedMessages":"837","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"838","messages":"839","suppressedMessages":"840","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"841","messages":"842","suppressedMessages":"843","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"844","messages":"845","suppressedMessages":"846","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"847","messages":"848","suppressedMessages":"849","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"850","messages":"851","suppressedMessages":"852","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"853","messages":"854","suppressedMessages":"855","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"856","messages":"857","suppressedMessages":"858","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"859","messages":"860","suppressedMessages":"861","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"862","messages":"863","suppressedMessages":"864","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"865","messages":"866","suppressedMessages":"867","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"868","messages":"869","suppressedMessages":"870","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"871","messages":"872","suppressedMessages":"873","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"874","messages":"875","suppressedMessages":"876","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"877","messages":"878","suppressedMessages":"879","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"880","messages":"881","suppressedMessages":"882","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"883","messages":"884","suppressedMessages":"885","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"886","messages":"887","suppressedMessages":"888","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"889","messages":"890","suppressedMessages":"891","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"892","messages":"893","suppressedMessages":"894","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"895","messages":"896","suppressedMessages":"897","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"898","messages":"899","suppressedMessages":"900","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"901","messages":"902","suppressedMessages":"903","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"904","messages":"905","suppressedMessages":"906","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"907","messages":"908","suppressedMessages":"909","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"910","messages":"911","suppressedMessages":"912","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"913","messages":"914","suppressedMessages":"915","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"916","messages":"917","suppressedMessages":"918","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"919","messages":"920","suppressedMessages":"921","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"922","messages":"923","suppressedMessages":"924","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"925","messages":"926","suppressedMessages":"927","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"928","messages":"929","suppressedMessages":"930","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"931","messages":"932","suppressedMessages":"933","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"934","messages":"935","suppressedMessages":"936","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"937","messages":"938","suppressedMessages":"939","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"940","messages":"941","suppressedMessages":"942","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"943","messages":"944","suppressedMessages":"945","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"946","messages":"947","suppressedMessages":"948","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"949","messages":"950","suppressedMessages":"951","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"952","messages":"953","suppressedMessages":"954","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"955","messages":"956","suppressedMessages":"957","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"958","messages":"959","suppressedMessages":"960","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"961","messages":"962","suppressedMessages":"963","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"964","messages":"965","suppressedMessages":"966","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"967","messages":"968","suppressedMessages":"969","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"970","messages":"971","suppressedMessages":"972","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"973","messages":"974","suppressedMessages":"975","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"976","messages":"977","suppressedMessages":"978","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"979","messages":"980","suppressedMessages":"981","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"982","messages":"983","suppressedMessages":"984","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"985","messages":"986","suppressedMessages":"987","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"988","messages":"989","suppressedMessages":"990","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"991","messages":"992","suppressedMessages":"993","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"994","messages":"995","suppressedMessages":"996","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"997","messages":"998","suppressedMessages":"999","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1000","messages":"1001","suppressedMessages":"1002","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1003","messages":"1004","suppressedMessages":"1005","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1006","messages":"1007","suppressedMessages":"1008","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1009","messages":"1010","suppressedMessages":"1011","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1012","messages":"1013","suppressedMessages":"1014","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1015","messages":"1016","suppressedMessages":"1017","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1018","messages":"1019","suppressedMessages":"1020","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1021","messages":"1022","suppressedMessages":"1023","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1024","messages":"1025","suppressedMessages":"1026","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1027","messages":"1028","suppressedMessages":"1029","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1030","messages":"1031","suppressedMessages":"1032","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1033","messages":"1034","suppressedMessages":"1035","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1036","messages":"1037","suppressedMessages":"1038","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1039","messages":"1040","suppressedMessages":"1041","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1042","messages":"1043","suppressedMessages":"1044","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1045","messages":"1046","suppressedMessages":"1047","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1048","messages":"1049","suppressedMessages":"1050","errorCount":1,"fatalErrorCount":1,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1051","messages":"1052","suppressedMessages":"1053","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1054","messages":"1055","suppressedMessages":"1056","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1057","messages":"1058","suppressedMessages":"1059","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1060","messages":"1061","suppressedMessages":"1062","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1063","messages":"1064","suppressedMessages":"1065","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1066","messages":"1067","suppressedMessages":"1068","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1069","messages":"1070","suppressedMessages":"1071","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1072","messages":"1073","suppressedMessages":"1074","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1075","messages":"1076","suppressedMessages":"1077","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1078","messages":"1079","suppressedMessages":"1080","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1081","messages":"1082","suppressedMessages":"1083","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1084","messages":"1085","suppressedMessages":"1086","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1087","messages":"1088","suppressedMessages":"1089","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1090","messages":"1091","suppressedMessages":"1092","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1093","messages":"1094","suppressedMessages":"1095","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1096","messages":"1097","suppressedMessages":"1098","errorCount":0,"fatalErrorCount":0,"warningCount":3,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1099","messages":"1100","suppressedMessages":"1101","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1102","messages":"1103","suppressedMessages":"1104","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1105","messages":"1106","suppressedMessages":"1107","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1108","messages":"1109","suppressedMessages":"1110","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1111","messages":"1112","suppressedMessages":"1113","errorCount":0,"fatalErrorCount":0,"warningCount":14,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1114","messages":"1115","suppressedMessages":"1116","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1117","messages":"1118","suppressedMessages":"1119","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1120","messages":"1121","suppressedMessages":"1122","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1123","messages":"1124","suppressedMessages":"1125","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1126","messages":"1127","suppressedMessages":"1128","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1129","messages":"1130","suppressedMessages":"1131","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1132","messages":"1133","suppressedMessages":"1134","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1135","messages":"1136","suppressedMessages":"1137","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1138","messages":"1139","suppressedMessages":"1140","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1141","messages":"1142","suppressedMessages":"1143","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1144","messages":"1145","suppressedMessages":"1146","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1147","messages":"1148","suppressedMessages":"1149","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1150","messages":"1151","suppressedMessages":"1152","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1153","messages":"1154","suppressedMessages":"1155","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1156","messages":"1157","suppressedMessages":"1158","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1159","messages":"1160","suppressedMessages":"1161","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1162","messages":"1163","suppressedMessages":"1164","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1165","messages":"1166","suppressedMessages":"1167","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1168","messages":"1169","suppressedMessages":"1170","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1171","messages":"1172","suppressedMessages":"1173","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1174","messages":"1175","suppressedMessages":"1176","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1177","messages":"1178","suppressedMessages":"1179","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1180","messages":"1181","suppressedMessages":"1182","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1183","messages":"1184","suppressedMessages":"1185","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1186","messages":"1187","suppressedMessages":"1188","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1189","messages":"1190","suppressedMessages":"1191","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1192","messages":"1193","suppressedMessages":"1194","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1195","messages":"1196","suppressedMessages":"1197","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1198","messages":"1199","suppressedMessages":"1200","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1201","messages":"1202","suppressedMessages":"1203","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1204","messages":"1205","suppressedMessages":"1206","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1207","messages":"1208","suppressedMessages":"1209","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1210","messages":"1211","suppressedMessages":"1212","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1213","messages":"1214","suppressedMessages":"1215","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1216","messages":"1217","suppressedMessages":"1218","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1219","messages":"1220","suppressedMessages":"1221","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1222","messages":"1223","suppressedMessages":"1224","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1225","messages":"1226","suppressedMessages":"1227","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1228","messages":"1229","suppressedMessages":"1230","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1231","messages":"1232","suppressedMessages":"1233","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1234","messages":"1235","suppressedMessages":"1236","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1237","messages":"1238","suppressedMessages":"1239","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1240","messages":"1241","suppressedMessages":"1242","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1243","messages":"1244","suppressedMessages":"1245","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1246","messages":"1247","suppressedMessages":"1248","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1249","messages":"1250","suppressedMessages":"1251","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1252","messages":"1253","suppressedMessages":"1254","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1255","messages":"1256","suppressedMessages":"1257","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1258","messages":"1259","suppressedMessages":"1260","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1261","messages":"1262","suppressedMessages":"1263","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1264","messages":"1265","suppressedMessages":"1266","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1267","messages":"1268","suppressedMessages":"1269","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1270","messages":"1271","suppressedMessages":"1272","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1273","messages":"1274","suppressedMessages":"1275","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1276","messages":"1277","suppressedMessages":"1278","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1279","messages":"1280","suppressedMessages":"1281","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1282","messages":"1283","suppressedMessages":"1284","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1285","messages":"1286","suppressedMessages":"1287","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1288","messages":"1289","suppressedMessages":"1290","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1291","messages":"1292","suppressedMessages":"1293","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1294","messages":"1295","suppressedMessages":"1296","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1297","messages":"1298","suppressedMessages":"1299","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1300","messages":"1301","suppressedMessages":"1302","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1303","messages":"1304","suppressedMessages":"1305","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1306","messages":"1307","suppressedMessages":"1308","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1309","messages":"1310","suppressedMessages":"1311","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1312","messages":"1313","suppressedMessages":"1314","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1315","messages":"1316","suppressedMessages":"1317","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1318","messages":"1319","suppressedMessages":"1320","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1321","messages":"1322","suppressedMessages":"1323","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1324","messages":"1325","suppressedMessages":"1326","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1327","messages":"1328","suppressedMessages":"1329","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1330","messages":"1331","suppressedMessages":"1332","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1333","messages":"1334","suppressedMessages":"1335","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1336","messages":"1337","suppressedMessages":"1338","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1339","messages":"1340","suppressedMessages":"1341","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1342","messages":"1343","suppressedMessages":"1344","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1345","messages":"1346","suppressedMessages":"1347","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1348","messages":"1349","suppressedMessages":"1350","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1351","messages":"1352","suppressedMessages":"1353","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1354","messages":"1355","suppressedMessages":"1356","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1357","messages":"1358","suppressedMessages":"1359","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1360","messages":"1361","suppressedMessages":"1362","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1363","messages":"1364","suppressedMessages":"1365","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1366","messages":"1367","suppressedMessages":"1368","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1369","messages":"1370","suppressedMessages":"1371","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1372","messages":"1373","suppressedMessages":"1374","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1375","messages":"1376","suppressedMessages":"1377","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1378","messages":"1379","suppressedMessages":"1380","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1381","messages":"1382","suppressedMessages":"1383","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1384","messages":"1385","suppressedMessages":"1386","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1387","messages":"1388","suppressedMessages":"1389","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1390","messages":"1391","suppressedMessages":"1392","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1393","messages":"1394","suppressedMessages":"1395","errorCount":0,"fatalErrorCount":0,"warningCount":2,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1396","messages":"1397","suppressedMessages":"1398","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1399","messages":"1400","suppressedMessages":"1401","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1402","messages":"1403","suppressedMessages":"1404","errorCount":0,"fatalErrorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1405","messages":"1406","suppressedMessages":"1407","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"1408","messages":"1409","suppressedMessages":"1410","errorCount":0,"fatalErrorCount":0,"warningCount":4,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"1411","messages":"1412","suppressedMessages":"1413","errorCount":0,"fatalErrorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\1707791500-InitialMigration.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\1707801786-AddAccountStatus.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\migrations\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\account.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\pubRecord.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\temp.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\user.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\video.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\workData.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\electron-env.d.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\event.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\log.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\schedule.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\store.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\table.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\browserWindow.d.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\BrowserWindowItem.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\BrowserWindow\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\controller.ts",["1414","1415"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\account\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\types\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\app.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\backup\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\controller.ts",["1416"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\container.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\decorators.ts",["1417"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\core\\metadata.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\plat.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\PlatformBase.ts",["1418","1419"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\douyin\\index.ts",["1420","1421","1422","1423","1424","1425","1426"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\Kwai\\index.ts",["1427","1428","1429","1430","1431","1432","1433","1434","1435","1436","1437","1438","1439","1440","1441","1442","1443","1444","1445","1446","1447","1448","1449","1450","1451","1452","1453","1454"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\wxSph\\index.ts",["1455","1456","1457","1458","1459","1460","1461","1462","1463","1464","1465","1466","1467","1468","1469","1470","1471","1472","1473","1474","1475","1476","1477","1478","1479","1480","1481","1482"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\platforms\\xhs\\index.ts",["1483","1484","1485","1486","1487","1488"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemBase.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemVideo.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\controller.ts",["1489"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\controller.ts",["1490"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\video\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\splash.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\controller.ts",["1491","1492","1493","1494"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\test\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tools\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\update.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\user\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\views.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\coomont.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\douyin.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\index.ts",["1495","1496","1497"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\kwai.type.ts",["1498"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\requestNet.ts",["1499","1500"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\shipinhao\\index.ts",["1501","1502","1503","1504","1505"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\shipinhao\\wxShp.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\utils\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\xiaohongshu\\index.ts",["1506","1507","1508"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\xiaohongshu\\xiaohongshu.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\preload\\index.ts",[],["1509"],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\tray\\systemTray.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\common.ts",["1510"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\ffmpeg\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\ffmpeg\\video.ts",["1511","1512"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\file.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\time.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\util\\windowOperate.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\douyin.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\finance.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\platform.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\request.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\task.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\tools.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\finance.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\hotTopic.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\task.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\topic.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\user-t.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\userWalletAccount.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\viralTitles.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\user.ts",["1513"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\App.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Choose\\ImgChoose.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Choose\\VideoChoose.tsx",["1514"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\GetCode.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\index.tsx",["1515","1516"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\Modal\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\update\\Progress\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\WebView\\index.tsx",["1517","1518"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\config\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\global\\table.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\hooks\\useCssVariables.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\account.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\app.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\publish.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\receiveMsg.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\tools.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\view.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\LayoutBody.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\Navigation\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\SysMenu\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\main.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\router\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\user.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\xiaohongshu.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\type\\electron-updater.d.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\clone.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\createPersistStore.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\index.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\regulars.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\storage.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\utils\\StroeEnum.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\components\\AddAccountModal.tsx",["1519"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\components\\addWalletAccount.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\userWalletAccount.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\finance\\userWalletRecord.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\LoginCore.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\PhoneLogin.tsx",["1520"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\components\\qrcodeLogin.tsx",["1521","1522"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\login\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\page.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\pubRecord\\page.tsx",["1523"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\textPage\\page.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\CommonPubSetting.tsx",["1524"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\NoChoosePage.tsx",["1525"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoChooseItem.tsx",["1526"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoCoverSeting.tsx",["1527"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_DouYin.tsx",["1528"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_KWAI.tsx",["1529"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_WxSph.tsx",["1530"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\VideoPubSetModal_XSH.tsx",["1531"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\LocationSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\TopicSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\useDebounceFetcher.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\UserSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\VideoPubSetModalCommon.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\components\\VideoPubSetModalVideo.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\VideoPubSetModal.tsx",["1532"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\videoPubSetModal.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\page.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\useVideoPageStore.ts",["1533"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\videoPage.d.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\ChooseAccountModule\\ChooseAccountModule.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\ChooseAccountModule\\components\\PlatChoose.tsx",["1534","1535","1536"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\page.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\statistics\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\carTask.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\comment.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\carInfo.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\mineInfo.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\popInfo.tsx",["1537"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\videoInfo.tsx",["1538","1539","1540"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\components\\withdraw.tsx",["1541","1542","1543"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\mineTask.tsx",["1544"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\popTask.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\task\\videoTask.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\trending\\hotTopic.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\trending\\index.tsx",["1545","1546","1547","1548","1549","1550","1551","1552","1553","1554","1555","1556","1557","1558"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\vite-env.d.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\controller.ts",["1559"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\douyin\\common.douyin.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\operate.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\operate.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\reply.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\account\\components\\AccountSidebar\\AccountSidebar.tsx",["1560"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\index.tsx",["1561"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\autoRun.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\autoRunRecord.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\tools.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\comment.ts",["1562"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\autoRun\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\plat\\Kwai\\sign\\KwaiSign.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\ErrorBoundary\\ErrorBoundary.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\VideoPlayer\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\autoRun.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\useImagePageStore.ts",["1563"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\PubAccountDetModule\\PubAccountDetModule.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\PubProgressModule\\PubProgressModule.tsx",["1564"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\SupportPlat\\SupportPlat.tsx",["1565","1566"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\replyComment.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\replyWorks.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\interact.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\tools.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\account.ts",["1567"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\pubStroe.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\AICreateTitle\\AICreateTitle.tsx",["1568"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\AICreateTitle\\useAICreateTitle.ts",["1569"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\addAutoRun.tsx",["1570"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\notice.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\Inform\\index.tsx",["1571","1572"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\icp\\replyother.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageLeftSetting\\ImageLeftSetting.tsx",["1573"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageLeftSetting\\ImgTextImagesView.tsx",["1574"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\hooks\\useImagePlatParams.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\ImageParamsSet_Douyin.tsx",["1575"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\children\\ImageParamsSet_XHS.tsx",["1576"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageParamsSet.tsx",["1577","1578"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageParamsSet.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ImageRightSettingCommon.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\components\\ParamsSettingDetails.tsx",["1579"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\components\\ImageRightSetting\\ImageRightSetting.tsx",["1580"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\imagePage\\imagePage.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonComponents.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonLocationSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonScheduledTimeSelect.tsx",["1581"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonTopicSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\CommonUserSelect.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\components\\CommonComponents\\DouyinCommonComponents.tsx",["1582"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\commentList.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\addAutoRun.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\replyComment.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\components\\replyWorks.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\replyother\\interact.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\imgText.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\plat\\pub\\PubItemImgText.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\imgText\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\publish\\imgText\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\CronSchedule.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\store\\commont.ts",["1583","1584"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\videoPage\\components\\VideoPubSetModal\\children\\hooks\\useVideoPubSetModal.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\hooks\\usePubParamsVerify.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\autoRun.tsx",["1585","1586"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\autoRunRecord.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\replyCommentRecord.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\publish\\children\\pubRecord\\components\\PubRecordDetails.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\reply\\components\\oneKeyReply.tsx",["1587"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\global\\cache.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\db\\models\\interactionRecord.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\api\\tracing.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\cacheData.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\controller.ts",["1588"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\interaction\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\reply\\cacheData.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\controller.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\module.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\electron\\main\\tracing\\service.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\cfg.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\feedback.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\tracing.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\api\\types\\platform.type.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\UploadImages\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\components\\WindowControlButtons\\WindowControlButtons.tsx",["1589"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\BellMessage\\index.tsx",["1590"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\layout\\UpdateLog\\index.tsx",["1591"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiRanking\\echarts-weekPie.ts",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiRanking\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\children\\aiToolWebview\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\components\\CycleSelects\\index.tsx",["1592","1593"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\components\\RankingTags\\index.tsx",["1594"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\aiTool\\index.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\components\\addAutoRun.tsx",["1595"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\components\\autoRunRecord.tsx",[],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\interaction\\index.tsx",["1596","1597","1598","1599"],[],"E:\\project-dev\\electron\\艺咖\\AttAiToEarn\\src\\views\\test\\index.tsx",[],[],{"ruleId":"1600","severity":1,"message":"1601","line":137,"column":24,"nodeType":null,"messageId":"1602","endLine":137,"endColumn":29},{"ruleId":"1600","severity":1,"message":"1601","line":167,"column":25,"nodeType":null,"messageId":"1602","endLine":167,"endColumn":30},{"ruleId":"1600","severity":1,"message":"1601","line":20,"column":20,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1603","line":21,"column":20,"nodeType":null,"messageId":"1602","endLine":21,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1604","line":237,"column":18,"nodeType":null,"messageId":"1602","endLine":237,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1605","line":242,"column":14,"nodeType":null,"messageId":"1602","endLine":242,"endColumn":20},{"ruleId":"1600","severity":1,"message":"1606","line":185,"column":21,"nodeType":null,"messageId":"1602","endLine":185,"endColumn":27},{"ruleId":"1600","severity":1,"message":"1607","line":590,"column":5,"nodeType":null,"messageId":"1602","endLine":590,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1608","line":591,"column":5,"nodeType":null,"messageId":"1602","endLine":591,"endColumn":9},{"ruleId":"1600","severity":1,"message":"1609","line":592,"column":5,"nodeType":null,"messageId":"1602","endLine":592,"endColumn":20},{"ruleId":"1600","severity":1,"message":"1610","line":593,"column":5,"nodeType":null,"messageId":"1602","endLine":593,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1607","line":618,"column":5,"nodeType":null,"messageId":"1602","endLine":618,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1610","line":619,"column":5,"nodeType":null,"messageId":"1602","endLine":619,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1611","line":122,"column":42,"nodeType":null,"messageId":"1602","endLine":122,"endColumn":44},{"ruleId":"1600","severity":1,"message":"1607","line":194,"column":5,"nodeType":null,"messageId":"1602","endLine":194,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1610","line":195,"column":5,"nodeType":null,"messageId":"1602","endLine":195,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1606","line":233,"column":21,"nodeType":null,"messageId":"1602","endLine":233,"endColumn":27},{"ruleId":"1600","severity":1,"message":"1607","line":302,"column":5,"nodeType":null,"messageId":"1602","endLine":302,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1608","line":303,"column":5,"nodeType":null,"messageId":"1602","endLine":303,"endColumn":9},{"ruleId":"1600","severity":1,"message":"1610","line":304,"column":5,"nodeType":null,"messageId":"1602","endLine":304,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1607","line":317,"column":5,"nodeType":null,"messageId":"1602","endLine":317,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1608","line":318,"column":5,"nodeType":null,"messageId":"1602","endLine":318,"endColumn":9},{"ruleId":"1600","severity":1,"message":"1609","line":319,"column":5,"nodeType":null,"messageId":"1602","endLine":319,"endColumn":20},{"ruleId":"1600","severity":1,"message":"1610","line":320,"column":5,"nodeType":null,"messageId":"1602","endLine":320,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1612","line":322,"column":25,"nodeType":null,"messageId":"1602","endLine":322,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":322,"column":34,"nodeType":null,"messageId":"1602","endLine":322,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1607","line":326,"column":5,"nodeType":null,"messageId":"1602","endLine":326,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1606","line":327,"column":5,"nodeType":null,"messageId":"1602","endLine":327,"endColumn":11},{"ruleId":"1600","severity":1,"message":"1614","line":328,"column":5,"nodeType":null,"messageId":"1602","endLine":328,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1607","line":334,"column":5,"nodeType":null,"messageId":"1602","endLine":334,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1615","line":335,"column":5,"nodeType":null,"messageId":"1602","endLine":335,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1614","line":336,"column":5,"nodeType":null,"messageId":"1602","endLine":336,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1616","line":337,"column":5,"nodeType":null,"messageId":"1602","endLine":337,"endColumn":11},{"ruleId":"1600","severity":1,"message":"1607","line":456,"column":18,"nodeType":null,"messageId":"1602","endLine":456,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1610","line":456,"column":41,"nodeType":null,"messageId":"1602","endLine":456,"endColumn":48},{"ruleId":"1600","severity":1,"message":"1612","line":457,"column":25,"nodeType":null,"messageId":"1602","endLine":457,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":457,"column":34,"nodeType":null,"messageId":"1602","endLine":457,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1607","line":463,"column":19,"nodeType":null,"messageId":"1602","endLine":463,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1610","line":463,"column":42,"nodeType":null,"messageId":"1602","endLine":463,"endColumn":49},{"ruleId":"1600","severity":1,"message":"1612","line":464,"column":25,"nodeType":null,"messageId":"1602","endLine":464,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":464,"column":34,"nodeType":null,"messageId":"1602","endLine":464,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1606","line":166,"column":21,"nodeType":null,"messageId":"1602","endLine":166,"endColumn":27},{"ruleId":"1600","severity":1,"message":"1607","line":226,"column":5,"nodeType":null,"messageId":"1602","endLine":226,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1608","line":227,"column":5,"nodeType":null,"messageId":"1602","endLine":227,"endColumn":9},{"ruleId":"1600","severity":1,"message":"1610","line":228,"column":5,"nodeType":null,"messageId":"1602","endLine":228,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1607","line":241,"column":5,"nodeType":null,"messageId":"1602","endLine":241,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1608","line":242,"column":5,"nodeType":null,"messageId":"1602","endLine":242,"endColumn":9},{"ruleId":"1600","severity":1,"message":"1609","line":243,"column":5,"nodeType":null,"messageId":"1602","endLine":243,"endColumn":20},{"ruleId":"1600","severity":1,"message":"1610","line":244,"column":5,"nodeType":null,"messageId":"1602","endLine":244,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1612","line":246,"column":25,"nodeType":null,"messageId":"1602","endLine":246,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":246,"column":34,"nodeType":null,"messageId":"1602","endLine":246,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1607","line":250,"column":5,"nodeType":null,"messageId":"1602","endLine":250,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1606","line":251,"column":5,"nodeType":null,"messageId":"1602","endLine":251,"endColumn":11},{"ruleId":"1600","severity":1,"message":"1614","line":252,"column":5,"nodeType":null,"messageId":"1602","endLine":252,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1607","line":258,"column":5,"nodeType":null,"messageId":"1602","endLine":258,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1615","line":259,"column":5,"nodeType":null,"messageId":"1602","endLine":259,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1614","line":260,"column":5,"nodeType":null,"messageId":"1602","endLine":260,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1616","line":261,"column":5,"nodeType":null,"messageId":"1602","endLine":261,"endColumn":11},{"ruleId":"1600","severity":1,"message":"1617","line":289,"column":11,"nodeType":null,"messageId":"1602","endLine":289,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1607","line":438,"column":18,"nodeType":null,"messageId":"1602","endLine":438,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1610","line":438,"column":41,"nodeType":null,"messageId":"1602","endLine":438,"endColumn":48},{"ruleId":"1600","severity":1,"message":"1612","line":439,"column":25,"nodeType":null,"messageId":"1602","endLine":439,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":439,"column":34,"nodeType":null,"messageId":"1602","endLine":439,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1607","line":445,"column":19,"nodeType":null,"messageId":"1602","endLine":445,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1610","line":445,"column":42,"nodeType":null,"messageId":"1602","endLine":445,"endColumn":49},{"ruleId":"1600","severity":1,"message":"1612","line":446,"column":25,"nodeType":null,"messageId":"1602","endLine":446,"endColumn":32},{"ruleId":"1600","severity":1,"message":"1613","line":446,"column":34,"nodeType":null,"messageId":"1602","endLine":446,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1607","line":450,"column":5,"nodeType":null,"messageId":"1602","endLine":450,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1610","line":451,"column":5,"nodeType":null,"messageId":"1602","endLine":451,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1618","line":149,"column":11,"nodeType":null,"messageId":"1602","endLine":149,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1606","line":235,"column":21,"nodeType":null,"messageId":"1602","endLine":235,"endColumn":27},{"ruleId":"1600","severity":1,"message":"1619","line":241,"column":24,"nodeType":null,"messageId":"1602","endLine":241,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1610","line":313,"column":5,"nodeType":null,"messageId":"1602","endLine":313,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1620","line":468,"column":11,"nodeType":null,"messageId":"1602","endLine":468,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1620","line":483,"column":11,"nodeType":null,"messageId":"1602","endLine":483,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1601","line":192,"column":25,"nodeType":null,"messageId":"1602","endLine":192,"endColumn":30},{"ruleId":"1600","severity":1,"message":"1601","line":35,"column":21,"nodeType":null,"messageId":"1602","endLine":35,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1601","line":42,"column":17,"nodeType":null,"messageId":"1602","endLine":42,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1601","line":56,"column":29,"nodeType":null,"messageId":"1602","endLine":56,"endColumn":34},{"ruleId":"1600","severity":1,"message":"1601","line":93,"column":18,"nodeType":null,"messageId":"1602","endLine":93,"endColumn":23},{"ruleId":"1600","severity":1,"message":"1601","line":157,"column":18,"nodeType":null,"messageId":"1602","endLine":157,"endColumn":23},{"ruleId":"1600","severity":1,"message":"1621","line":306,"column":33,"nodeType":null,"messageId":"1602","endLine":306,"endColumn":37},{"ruleId":"1600","severity":1,"message":"1622","line":306,"column":39,"nodeType":null,"messageId":"1602","endLine":306,"endColumn":47},{"ruleId":"1600","severity":1,"message":"1610","line":696,"column":5,"nodeType":null,"messageId":"1602","endLine":696,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1623","line":3,"column":11,"nodeType":null,"messageId":"1602","endLine":3,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1624","line":99,"column":43,"nodeType":null,"messageId":"1602","endLine":99,"endColumn":46},{"ruleId":"1600","severity":1,"message":"1625","line":99,"column":48,"nodeType":null,"messageId":"1602","endLine":99,"endColumn":51},{"ruleId":"1600","severity":1,"message":"1626","line":82,"column":5,"nodeType":null,"messageId":"1602","endLine":82,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1601","line":87,"column":16,"nodeType":null,"messageId":"1602","endLine":87,"endColumn":21},{"ruleId":"1600","severity":1,"message":"1627","line":87,"column":23,"nodeType":null,"messageId":"1602","endLine":87,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1613","line":289,"column":40,"nodeType":null,"messageId":"1602","endLine":289,"endColumn":46},{"ruleId":"1600","severity":1,"message":"1628","line":324,"column":16,"nodeType":null,"messageId":"1602","endLine":324,"endColumn":17},{"ruleId":"1600","severity":1,"message":"1629","line":115,"column":43,"nodeType":null,"messageId":"1602","endLine":115,"endColumn":50},{"ruleId":"1600","severity":1,"message":"1630","line":310,"column":15,"nodeType":null,"messageId":"1602","endLine":310,"endColumn":23},{"ruleId":"1600","severity":1,"message":"1613","line":1302,"column":40,"nodeType":null,"messageId":"1602","endLine":1302,"endColumn":46},{"ruleId":"1631","severity":2,"message":"1632","line":125,"column":3,"nodeType":"1633","messageId":"1634","endLine":125,"endColumn":58,"suppressions":"1635"},{"ruleId":"1600","severity":1,"message":"1628","line":62,"column":16,"nodeType":null,"messageId":"1602","endLine":62,"endColumn":17},{"ruleId":"1600","severity":1,"message":"1636","line":15,"column":7,"nodeType":null,"messageId":"1602","endLine":15,"endColumn":16},{"ruleId":"1600","severity":1,"message":"1613","line":25,"column":42,"nodeType":null,"messageId":"1602","endLine":25,"endColumn":48},{"ruleId":"1600","severity":1,"message":"1608","line":51,"column":20,"nodeType":null,"messageId":"1602","endLine":51,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1637","line":138,"column":3,"nodeType":null,"messageId":"1602","endLine":138,"endColumn":15},{"ruleId":"1600","severity":1,"message":"1638","line":76,"column":6,"nodeType":null,"messageId":"1602","endLine":76,"endColumn":12},{"ruleId":"1600","severity":1,"message":"1639","line":76,"column":44,"nodeType":null,"messageId":"1602","endLine":76,"endColumn":48},{"ruleId":"1600","severity":1,"message":"1640","line":31,"column":7,"nodeType":null,"messageId":"1602","endLine":31,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1628","line":44,"column":66,"nodeType":null,"messageId":"1602","endLine":44,"endColumn":67},{"ruleId":"1600","severity":1,"message":"1640","line":19,"column":7,"nodeType":null,"messageId":"1602","endLine":19,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":15,"column":37,"nodeType":null,"messageId":"1602","endLine":15,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1641","line":87,"column":18,"nodeType":null,"messageId":"1602","endLine":87,"endColumn":34},{"ruleId":"1600","severity":1,"message":"1642","line":96,"column":18,"nodeType":null,"messageId":"1602","endLine":96,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1643","line":178,"column":7,"nodeType":null,"messageId":"1602","endLine":178,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1640","line":25,"column":34,"nodeType":null,"messageId":"1602","endLine":25,"endColumn":37},{"ruleId":"1600","severity":1,"message":"1640","line":22,"column":39,"nodeType":null,"messageId":"1602","endLine":22,"endColumn":42},{"ruleId":"1600","severity":1,"message":"1640","line":34,"column":7,"nodeType":null,"messageId":"1602","endLine":34,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":74,"column":7,"nodeType":null,"messageId":"1602","endLine":74,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":84,"column":7,"nodeType":null,"messageId":"1602","endLine":84,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":19,"column":7,"nodeType":null,"messageId":"1602","endLine":19,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":89,"column":7,"nodeType":null,"messageId":"1602","endLine":89,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":20,"column":7,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":10},{"ruleId":null,"fatal":true,"severity":2,"message":"1644","line":267,"column":51,"nodeType":null},{"ruleId":"1600","severity":1,"message":"1645","line":83,"column":17,"nodeType":null,"messageId":"1602","endLine":83,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1646","line":112,"column":21,"nodeType":null,"messageId":"1602","endLine":112,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1646","line":121,"column":21,"nodeType":null,"messageId":"1602","endLine":121,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1647","line":167,"column":44,"nodeType":null,"messageId":"1602","endLine":167,"endColumn":47},{"ruleId":"1600","severity":1,"message":"1648","line":75,"column":16,"nodeType":null,"messageId":"1602","endLine":75,"endColumn":21},{"ruleId":"1600","severity":1,"message":"1648","line":99,"column":14,"nodeType":null,"messageId":"1602","endLine":99,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1617","line":116,"column":13,"nodeType":null,"messageId":"1602","endLine":116,"endColumn":16},{"ruleId":"1600","severity":1,"message":"1648","line":122,"column":14,"nodeType":null,"messageId":"1602","endLine":122,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1617","line":54,"column":13,"nodeType":null,"messageId":"1602","endLine":54,"endColumn":16},{"ruleId":"1600","severity":1,"message":"1648","line":57,"column":14,"nodeType":null,"messageId":"1602","endLine":57,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1649","line":69,"column":9,"nodeType":null,"messageId":"1602","endLine":69,"endColumn":30},{"ruleId":"1600","severity":1,"message":"1650","line":277,"column":9,"nodeType":null,"messageId":"1602","endLine":277,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1651","line":86,"column":11,"nodeType":null,"messageId":"1602","endLine":86,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1652","line":149,"column":11,"nodeType":null,"messageId":"1602","endLine":149,"endColumn":16},{"ruleId":"1600","severity":1,"message":"1653","line":220,"column":10,"nodeType":null,"messageId":"1602","endLine":220,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1654","line":220,"column":24,"nodeType":null,"messageId":"1602","endLine":220,"endColumn":39},{"ruleId":"1600","severity":1,"message":"1655","line":225,"column":10,"nodeType":null,"messageId":"1602","endLine":225,"endColumn":21},{"ruleId":"1600","severity":1,"message":"1656","line":230,"column":10,"nodeType":null,"messageId":"1602","endLine":230,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1657","line":233,"column":10,"nodeType":null,"messageId":"1602","endLine":233,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1658","line":233,"column":27,"nodeType":null,"messageId":"1602","endLine":233,"endColumn":45},{"ruleId":"1600","severity":1,"message":"1659","line":240,"column":10,"nodeType":null,"messageId":"1602","endLine":240,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1660","line":240,"column":21,"nodeType":null,"messageId":"1602","endLine":240,"endColumn":33},{"ruleId":"1600","severity":1,"message":"1661","line":241,"column":10,"nodeType":null,"messageId":"1602","endLine":241,"endColumn":28},{"ruleId":"1600","severity":1,"message":"1662","line":241,"column":30,"nodeType":null,"messageId":"1602","endLine":241,"endColumn":51},{"ruleId":"1600","severity":1,"message":"1663","line":298,"column":9,"nodeType":null,"messageId":"1602","endLine":298,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1664","line":1024,"column":9,"nodeType":null,"messageId":"1602","endLine":1024,"endColumn":28},{"ruleId":"1600","severity":1,"message":"1601","line":361,"column":5,"nodeType":null,"messageId":"1602","endLine":361,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":120,"column":7,"nodeType":null,"messageId":"1602","endLine":120,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1628","line":138,"column":39,"nodeType":null,"messageId":"1602","endLine":138,"endColumn":40},{"ruleId":"1600","severity":1,"message":"1646","line":25,"column":10,"nodeType":null,"messageId":"1602","endLine":25,"endColumn":11},{"ruleId":"1600","severity":1,"message":"1645","line":58,"column":17,"nodeType":null,"messageId":"1602","endLine":58,"endColumn":25},{"ruleId":"1600","severity":1,"message":"1640","line":31,"column":7,"nodeType":null,"messageId":"1602","endLine":31,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":17,"column":7,"nodeType":null,"messageId":"1602","endLine":17,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1665","line":31,"column":51,"nodeType":null,"messageId":"1602","endLine":31,"endColumn":52},{"ruleId":"1600","severity":1,"message":"1645","line":30,"column":16,"nodeType":null,"messageId":"1602","endLine":30,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1640","line":33,"column":7,"nodeType":null,"messageId":"1602","endLine":33,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1645","line":31,"column":16,"nodeType":null,"messageId":"1602","endLine":31,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1666","line":23,"column":10,"nodeType":null,"messageId":"1602","endLine":23,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1667","line":17,"column":9,"nodeType":null,"messageId":"1602","endLine":17,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1668","line":33,"column":12,"nodeType":null,"messageId":"1602","endLine":33,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1640","line":19,"column":34,"nodeType":null,"messageId":"1602","endLine":19,"endColumn":37},{"ruleId":"1600","severity":1,"message":"1640","line":16,"column":35,"nodeType":null,"messageId":"1602","endLine":16,"endColumn":38},{"ruleId":"1600","severity":1,"message":"1669","line":89,"column":48,"nodeType":null,"messageId":"1602","endLine":89,"endColumn":49},{"ruleId":"1600","severity":1,"message":"1669","line":14,"column":48,"nodeType":null,"messageId":"1602","endLine":14,"endColumn":49},{"ruleId":"1600","severity":1,"message":"1640","line":24,"column":7,"nodeType":null,"messageId":"1602","endLine":24,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1646","line":109,"column":21,"nodeType":null,"messageId":"1602","endLine":109,"endColumn":22},{"ruleId":"1600","severity":1,"message":"1640","line":28,"column":7,"nodeType":null,"messageId":"1602","endLine":28,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":20,"column":35,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":38},{"ruleId":"1600","severity":1,"message":"1640","line":35,"column":7,"nodeType":null,"messageId":"1602","endLine":35,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1628","line":285,"column":32,"nodeType":null,"messageId":"1602","endLine":285,"endColumn":33},{"ruleId":"1600","severity":1,"message":"1670","line":20,"column":11,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":14},{"ruleId":"1600","severity":1,"message":"1645","line":20,"column":16,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":24},{"ruleId":"1600","severity":1,"message":"1628","line":160,"column":29,"nodeType":null,"messageId":"1602","endLine":160,"endColumn":30},{"ruleId":"1600","severity":1,"message":"1628","line":171,"column":29,"nodeType":null,"messageId":"1602","endLine":171,"endColumn":30},{"ruleId":"1600","severity":1,"message":"1671","line":25,"column":10,"nodeType":null,"messageId":"1602","endLine":25,"endColumn":18},{"ruleId":"1600","severity":1,"message":"1601","line":175,"column":5,"nodeType":null,"messageId":"1602","endLine":175,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":18,"column":7,"nodeType":null,"messageId":"1602","endLine":18,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":43,"column":39,"nodeType":null,"messageId":"1602","endLine":43,"endColumn":42},{"ruleId":"1600","severity":1,"message":"1640","line":19,"column":36,"nodeType":null,"messageId":"1602","endLine":19,"endColumn":39},{"ruleId":"1600","severity":1,"message":"1672","line":105,"column":10,"nodeType":null,"messageId":"1602","endLine":105,"endColumn":17},{"ruleId":"1600","severity":1,"message":"1640","line":124,"column":7,"nodeType":null,"messageId":"1602","endLine":124,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1640","line":20,"column":7,"nodeType":null,"messageId":"1602","endLine":20,"endColumn":10},{"ruleId":"1600","severity":1,"message":"1666","line":23,"column":10,"nodeType":null,"messageId":"1602","endLine":23,"endColumn":19},{"ruleId":"1600","severity":1,"message":"1673","line":25,"column":20,"nodeType":null,"messageId":"1602","endLine":25,"endColumn":31},{"ruleId":"1600","severity":1,"message":"1674","line":60,"column":12,"nodeType":null,"messageId":"1602","endLine":60,"endColumn":26},{"ruleId":"1600","severity":1,"message":"1608","line":60,"column":27,"nodeType":null,"messageId":"1602","endLine":60,"endColumn":31},{"ruleId":"1600","severity":1,"message":"1628","line":92,"column":27,"nodeType":null,"messageId":"1602","endLine":92,"endColumn":28},"@typescript-eslint/no-unused-vars","'event' is defined but never used.","unusedVar","'target' is defined but never used.","'params' is defined but never used.","'cookie' is defined but never used.","'dataId' is defined but never used.","'account' is defined but never used.","'data' is defined but never used.","'root_comment_id' is defined but never used.","'pcursor' is defined but never used.","'i1' is defined but never used.","'resolve' is defined but never used.","'reject' is defined but never used.","'content' is defined but never used.","'commentId' is defined but never used.","'option' is defined but never used.","'res' is assigned a value but never used.","'pageSize' is assigned a value but never used.","'T' is defined but never used.","'ret' is assigned a value but never used.","'line' is defined but never used.","'sourceId' is defined but never used.","'AbConfig' is defined but never used.","'err' is defined but never used.","'res' is defined but never used.","'partition' is defined but never used.","'url' is defined but never used.","'e' is defined but never used.","'cookies' is assigned a value but never used.","'dataList' is assigned a value but never used.","@typescript-eslint/no-unused-expressions","Expected an assignment or function call and instead saw an expression.","ExpressionStatement","unusedExpression",["1675"],"'__dirname' is assigned a value but never used.","'onChooseFail' is defined but never used.","'_event' is defined but never used.","'args' is defined but never used.","'ref' is defined but never used.","'wxGzhQrcodelogin' is defined but never used.","'info' is defined but never used.","'selectedRows' is defined but never used.","Parsing error: ':' expected.","'storeApi' is defined but never used.","'_' is assigned a value but never used.","'key' is defined but never used.","'error' is defined but never used.","'renderAccountTypeTags' is assigned a value but never used.","'refreshTaskList' is assigned a value but never used.","'TopicResponse' is defined but never used.","'Topic' is defined but never used.","'rankingItems' is assigned a value but never used.","'setRankingItems' is assigned a value but never used.","'currentPage' is assigned a value but never used.","'categoryLoading' is assigned a value but never used.","'topicCategories' is assigned a value but never used.","'setTopicCategories' is assigned a value but never used.","'topicList' is assigned a value but never used.","'setTopicList' is assigned a value but never used.","'topicSubCategories' is assigned a value but never used.","'setTopicSubCategories' is assigned a value but never used.","'timeRangeOptions' is assigned a value but never used.","'handleMsgTypeSelect' is assigned a value but never used.","'k' is defined but never used.","'cycleType' is assigned a value but never used.","'e' is assigned a value but never used.","'onChooseItem' is defined but never used.","'_' is defined but never used.","'get' is defined but never used.","'workData' is assigned a value but never used.","'getDays' is defined but never used.","'setPageInfo' is assigned a value but never used.","'openAddAutoRun' is defined but never used.",{"kind":"1676","justification":"1677"},"directive",""] \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.eslintignore b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintignore new file mode 100644 index 000000000..80727c2ed --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintignore @@ -0,0 +1,34 @@ +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed + +# Coverage directory used by tools like istanbul +coverage +.eslintcache + +# Dependency directory +# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git +node_modules + +# OSX +.DS_Store + +release/app/dist +release/build +.erb/dll + +.idea +npm-debug.log.* +*.css.d.ts +*.sass.d.ts +*.scss.d.ts + +# eslint ignores hidden directories by default: +# https://github.com/eslint/eslint/issues/8429 +!.erb +kuaiShosignCore.js diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.eslintrc.cjs b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintrc.cjs new file mode 100644 index 000000000..39c9420f2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.eslintrc.cjs @@ -0,0 +1,34 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: [ + '@typescript-eslint/eslint-plugin', + 'unused-imports', + "eslint-plugin-react" + ], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + "unused-imports/no-unused-imports": "error", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-unused-vars": "warn", + "prettier/prettier": ["error", { "endOfLine": "auto" }], + "@typescript-eslint/ban-ts-comment": "off" + }, +}; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.gitignore b/project/aitoearn-wxplat/project/aitoearn-electron/.gitignore new file mode 100644 index 000000000..4b305da46 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.gitignore @@ -0,0 +1,45 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +dist-electron +release +*.local + +# Editor directories and files +.vscode/.debug.env +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# .env + +#lockfile +# package-lock.json +# pnpm-lock.yaml +# yarn.lock +/test-results/ +/playwright-report/ +/playwright/.cache/ + +# 忽略所有.db文件 +*.db + +# 忽略所有.exe文件(太) +*.exe + +# 忽略/public/bin目录下的非.md文件 +public/bin/* +!public/bin/webkit +!public/bin/*.md diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.hintrc b/project/aitoearn-wxplat/project/aitoearn-electron/.hintrc new file mode 100644 index 000000000..ae62a1a07 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.hintrc @@ -0,0 +1,13 @@ +{ + "extends": [ + "development" + ], + "hints": { + "axe/text-alternatives": [ + "default", + { + "image-alt": "off" + } + ] + } +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.npmrc b/project/aitoearn-wxplat/project/aitoearn-electron/.npmrc new file mode 100644 index 000000000..ec8540468 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.npmrc @@ -0,0 +1,8 @@ +# For electron-builder +# https://github.com/electron-userland/electron-builder/issues/6289#issuecomment-1042620422 +shamefully-hoist=true + +registry="https://registry.npmmirror.com" +# For China 🇨🇳 developers +electron_mirror=https://npmmirror.com/mirrors/electron/ +ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.prettierrc b/project/aitoearn-wxplat/project/aitoearn-electron/.prettierrc new file mode 100644 index 000000000..9d36f26d8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.prettierrc @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "endOfLine": "auto" +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/.vite.config.flat.txt b/project/aitoearn-wxplat/project/aitoearn-electron/.vite.config.flat.txt new file mode 100644 index 000000000..fb2becd87 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/.vite.config.flat.txt @@ -0,0 +1,78 @@ +import { rmSync } from 'node:fs' +import path from 'node:path' +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import electron from 'vite-plugin-electron' +import renderer from 'vite-plugin-electron-renderer' +import pkg from './package.json' + +// https://vitejs.dev/config/ +export default defineConfig(({ command }) => { + rmSync('dist-electron', { recursive: true, force: true }) + + const isServe = command === 'serve' + const isBuild = command === 'build' + const sourcemap = isServe || !!process.env.VSCODE_DEBUG + + return { + resolve: { + alias: { + '@': path.join(__dirname, 'src') + }, + }, + plugins: [ + react(), + electron([ + { + // Main-Process entry file of the Electron App. + entry: 'electron/main/index.tsx', + onstart(options) { + if (process.env.VSCODE_DEBUG) { + console.log(/* For `.vscode/.debug.script.mjs` */'[startup] Electron App') + } else { + options.startup() + } + }, + vite: { + build: { + sourcemap, + minify: isBuild, + outDir: 'dist-electron/main', + rollupOptions: { + external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + }, + }, + }, + }, + { + entry: 'electron/preload/index.tsx', + onstart(options) { + // Notify the Renderer-Process to reload the page when the Preload-Scripts build is complete, + // instead of restarting the entire Electron App. + options.reload() + }, + vite: { + build: { + sourcemap: sourcemap ? 'inline' : undefined, // #332 + minify: isBuild, + outDir: 'dist-electron/preload', + rollupOptions: { + external: Object.keys('dependencies' in pkg ? pkg.dependencies : {}), + }, + }, + }, + } + ]), + // Use Node.js API in the Renderer-process + renderer(), + ], + server: process.env.VSCODE_DEBUG && (() => { + const url = new URL(pkg.debug.env.VITE_DEV_SERVER_URL) + return { + host: url.hostname, + port: +url.port, + } + })(), + clearScreen: false, + } +}) diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/build/icons/512x512.png b/project/aitoearn-wxplat/project/aitoearn-electron/build/icons/512x512.png new file mode 100644 index 000000000..ac881947c Binary files /dev/null and b/project/aitoearn-wxplat/project/aitoearn-electron/build/icons/512x512.png differ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/AccountEnum.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/AccountEnum.ts new file mode 100644 index 000000000..74520dc6b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/AccountEnum.ts @@ -0,0 +1,25 @@ +// 平台类型 +export enum PlatType { + Douyin = 'douyin', // 抖音 + Xhs = 'xhs', // 小红书 + WxSph = 'wxSph', // 微信视频号 + KWAI = 'KWAI', // 快手 +} + +// 账号状态 +export enum AccountStatus { + // 未失效 + USABLE = 0, + // 失效 + DISABLE = 1, +} + +// 小红书账号异常状态 +export enum XhsAccountAbnormal { + // 账号正常 + Normal = 1, + // 账号异常(无法发布视频) + Abnormal = 2, +} + +export const defaultAccountGroupId = 1; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/UtilsEnum.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/UtilsEnum.ts new file mode 100644 index 000000000..181725679 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/UtilsEnum.ts @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:31 + * @LastEditTime: 2025-03-23 19:39:34 + * @LastEditors: nevin + * @Description: 工具枚举 + */ + +// 发送消息的事件key +export enum SendChannelEnum { + // 账户登录或更新 + AccountLoginFinish = 'AccountLoginFinish', + // 图文发布进度 + ImgTextPublishProgress = 'ImgTextPublishProgress', + // 视频发布进度发送 + VideoPublishProgress = 'VideoPublishProgress', + // 自动运行进度或状态 + AutoRun = 'AutoRun', + CommentRelyProgress = 'CommentRelyProgress', + // 评论互动进度 + InteractionProgress = 'InteractionProgress', + // 视频发布完成后待审核变成审核的事件 + VideoAuditFinish = 'VideoAuditFinish', +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/plat/douyin/common.douyin.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/plat/douyin/common.douyin.ts new file mode 100644 index 000000000..820e2c6cd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/plat/douyin/common.douyin.ts @@ -0,0 +1,15 @@ +// 自主声明 +export enum DeclarationDouyin { + // 内容由AI生成 + AIGC = 'aigc', + // 可能会引人不适 + MaybeUnsuitable = 'maybe_unsuitable', + // 虚拟作品。仅供娱乐 + OnlyFunNew = 'only_fun_new', + // 危险行为,请勿模仿 + DangerousBehavior = 'dangerous_behavior', + // 内容自行拍摄 + SelfShoot = 'self_shoot', + // 内容取材网络 + FromNetV3 = 'from_net_v3', +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/publish/PublishEnum.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/publish/PublishEnum.ts new file mode 100644 index 000000000..29abc6a04 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/publish/PublishEnum.ts @@ -0,0 +1,24 @@ +// 发布类型 +export enum PubType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 + ImageText = 'image-text', // 图文 +} + +// 可见性 +export enum VisibleTypeEnum { + // 所有人可见 + Public = 1, + // 仅自己可见 + Private = 2, + // 好友可见 + Friend = 3, +} + +export enum PubStatus { + UNPUBLISH = 0, // 未发布/草稿 + RELEASED = 1, // 已发布 + FAIL = 2, // 发布失败 + PartSuccess = 3, // 部分成功 + Audit = 4, // 审核中 +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/regular.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/regular.ts new file mode 100644 index 000000000..416b29e3f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/regular.ts @@ -0,0 +1,3 @@ +// IP校验正则 +export const ipv4Regular = + /^((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.){3}(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])(?::(?:[0-9]|[1-9][0-9]{1,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$/; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/apiServer.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/apiServer.ts new file mode 100644 index 000000000..aec5bcc6c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/apiServer.ts @@ -0,0 +1,11 @@ +/* + * @Author: nevin + * @Date: 2025-02-22 12:27:40 + * @LastEditTime: 2025-02-22 12:37:05 + * @LastEditors: nevin + * @Description: + */ +export interface TimeTemp { + createTime: string; + updateTime: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/interaction.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/interaction.ts new file mode 100644 index 000000000..fb23b2853 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/interaction.ts @@ -0,0 +1,18 @@ +// 自动任务的进度标识 +export enum AutorWorksInteractionScheduleEvent { + Start = 'start', // 开始 + GetCommentListStart = 'getCommentListStart', // 获取评论列表开始 + GetCommentListEnd = 'getCommentListEnd', // 获取评论列表结束 + ReplyCommentStart = 'replyCommentStart', // 评论开始 + ReplyCommentEnd = 'replyCommentEnd', // 评论结束 + End = 'end', // 结束 + Error = 'error', // 错误 +} + +export const AutorWorksInteractionScheduleEventMap = new Map([ + [AutorWorksInteractionScheduleEvent.Start, '开始'], + [AutorWorksInteractionScheduleEvent.GetCommentListStart, '获取评论列表开始'], + [AutorWorksInteractionScheduleEvent.GetCommentListEnd, '获取评论列表结束'], + [AutorWorksInteractionScheduleEvent.ReplyCommentStart, '评论开始'], + [AutorWorksInteractionScheduleEvent.ReplyCommentEnd, '评论结束'], +]); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/reply.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/reply.ts new file mode 100644 index 000000000..ce92df630 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/reply.ts @@ -0,0 +1,18 @@ +// 自动任务的进度标识 +export enum AutorReplyCommentScheduleEvent { + Start = 'start', // 开始 + GetCommentListStart = 'getCommentListStart', // 获取评论列表开始 + GetCommentListEnd = 'getCommentListEnd', // 获取评论列表结束 + ReplyCommentStart = 'replyCommentStart', // 评论开始 + ReplyCommentEnd = 'replyCommentEnd', // 评论结束 + End = 'end', // 结束 + Error = 'error', // 错误 +} + +export const AutorReplyCommentScheduleEventTagStrMap = new Map([ + [AutorReplyCommentScheduleEvent.Start, '开始'], + [AutorReplyCommentScheduleEvent.GetCommentListStart, '获取评论列表开始'], + [AutorReplyCommentScheduleEvent.GetCommentListEnd, '获取评论列表结束'], + [AutorReplyCommentScheduleEvent.ReplyCommentStart, '评论开始'], + [AutorReplyCommentScheduleEvent.ReplyCommentEnd, '评论结束'], +]); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/task.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/task.ts new file mode 100644 index 000000000..0c0dea2e4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/types/task.ts @@ -0,0 +1,91 @@ +/* + * @Author: nevin + * @Date: 2025-02-22 12:27:40 + * @LastEditTime: 2025-03-03 17:59:13 + * @LastEditors: nevin + * @Description: 任务 + */ +import { PlatType } from '../AccountEnum'; +import { TimeTemp } from './apiServer'; + +export enum TaskType { + PRODUCT = 'product', // 挂车市场任务 + ARTICLE = 'article', // 文章任务 + PROMOTION = 'promotion', // 推广任务 + VIDEO = 'video', // 视频任务 + INTERACTION = 'interaction', // 互动任务 +} + +export const TaskTypeName = new Map([ + [TaskType.PRODUCT, '挂车市场任务'], + [TaskType.ARTICLE, '文章任务'], + [TaskType.PROMOTION, '推广任务'], + [TaskType.VIDEO, '视频任务'], + [TaskType.INTERACTION, '互动任务'], +]); + +export enum TaskStatus { + ACTIVE = 'active', // 激活 + COMPLETED = 'completed', // 完成 + CANCELLED = 'cancelled', // 取消 +} +export const TaskStatusName = new Map([ + [TaskStatus.ACTIVE, '进行中'], + [TaskStatus.COMPLETED, '完成'], + [TaskStatus.CANCELLED, '取消'], +]); + +interface TaskData { + title: string; + desc?: string; +} +export interface TaskVideo extends TaskData { + videoUrl: string; +} + +export interface TaskArticle extends TaskData { + imageList: string[]; + topicList: string[]; +} + +export interface TaskPromotion extends TaskData {} + +export interface TaskProduct extends TaskData { + price: number; + sales?: number; +} + +export interface TaskInteraction extends TaskData { + accountType: PlatType; // 平台类型 + worksId: string; // 作品ID + authorId?: string; // 作者ID + commentContent?: string; // 评论内容,不填则使用AI +} + +export type TaskDataInfo = + | TaskProduct + | TaskPromotion + | TaskVideo + | TaskArticle + | TaskInteraction; +export interface Task extends TimeTemp { + _id: string; + id: string; + screenshotUrls: any; + title: string; + description: string; + type: TaskType; + dataInfo: T; + imageUrl: string; + keepTime: number; // 保持时间(秒) + requiresShoppingCart: boolean; // 是否需要挂购物车 + maxRecruits: number; // 最大招募人数 + currentRecruits: number; // 当前招募人数 + deadline: string; // 任务截止时间 + firstTimeBonus: number; // 首次任务奖励 + reward: number; // 任务奖励金额 + status: TaskStatus; // 'active' | 'completed' | 'cancelled' + accountTypes: PlatType[]; + isAccepted?: boolean; // 是否已经接受任务 + requirement?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.ts new file mode 100644 index 000000000..3842d4b53 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.ts @@ -0,0 +1,75 @@ +// 根据文件路径获取文件名和后缀 +import { ProxyInfo } from '@@/utils.type'; + +// 提取路径中的文件名 +export function getFilePathNameCommon(path: string) { + if (!path) + return { + filename: '', + suffix: '', + }; + const path1 = path.split('\\')[path.split('\\').length - 1]; + const filename = path1.split('/')[path1.split('/').length - 1]; + return { + filename, + suffix: filename.split('.')[filename.split('.').length - 1], + }; +} + +// 等待n毫秒 +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * 重试 + * @param max 重试上限 + * @param callback 每次循环的回调,返回boolean,为true则会结束循环 + * @param interval 重试间隔时间 + * @returns true=成功,false=失败 + */ +export async function RetryWhile( + callback: (count: number) => Promise, + max: number, + interval: number = 1000, +) { + let count = 0; + let flag = true; + while (true) { + const isEnd = await callback(count); + if (isEnd === true) break; + if (count > max) { + flag = false; + break; + } + count++; + await sleep(interval); + console.log(`开始第 ${count} 次重试`); + } + return flag; +} + +/** + * 代理解析 + * @param proxyString + */ +export function parseProxyString(proxyString: string): ProxyInfo | false { + const regex = + /^(?:(\w+):\/\/)?([\d.]+:\d+)(?::([^:]+):([^{}\s]+))?(?:\[(.*?)\])?(?:{(.*?)})?$/; + + const match = proxyString.match(regex); + if (!match) { + return false; // 无法解析则返回 false + } + + const [, protocol, ipAndPort, username, password, refreshUrl, remark] = match; + + return { + protocol: protocol || 'http', // 如果未提供协议,默认为 http + ipAndPort, + username, + password, + refreshUrl, + remark, + }; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.type.ts b/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.type.ts new file mode 100644 index 000000000..8910c043a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/commont/utils.type.ts @@ -0,0 +1,14 @@ +export interface ProxyInfo { + // 协议: 如 http, socks5 + protocol: string; + // IP+端口: 如 192.168.0.1:8000 + ipAndPort: string; + // 代理账号: 可选 + username?: string; + // 代理密码: 可选 + password?: string; + // 刷新URL: 可选 + refreshUrl?: string; + // 备注: 可选 + remark?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.json b/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.json new file mode 100644 index 000000000..4b48fec94 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "cn.aitoearn.pc", + "productName": "哎哟赚AiToEarn", + "asar": true, + "asarUnpack": ["**/node_modules/sharp/**/*", "**/node_modules/@img/**/*"], + "directories": { + "output": "release/${version}" + }, + "files": ["dist-electron", "dist"], + "mac": { + "gatekeeperAssess": false, + "hardenedRuntime": true, + "entitlements": "scripts/entitlements.mac.plist", + "entitlementsInherit": "scripts/entitlements.mac.plist", + "identity": null, + "artifactName": "${productName}-${version}-${arch}.${ext}", + "target": [ + { + "target": "default", + "arch": ["x64", "arm64"] + } + ] + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": ["x64"] + } + ], + "artifactName": "${productName}-${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + "publish": { + "provider": "generic", + "channel": "latest", + "url": "https://ylzsfile.yikart.cn/att/" + }, + "extraResources": [ + { + "from": "public/assets", + "to": "assets", + "filter": ["**/*"] + } + ] +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.sign.json.back b/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.sign.json.back new file mode 100644 index 000000000..490a7f7dd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron-builder.sign.json.back @@ -0,0 +1,52 @@ +{ + "$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json", + "appId": "cn.aitoearn.pc", + "productName": "哎哟赚AiToEarn", + "asar": true, + "asarUnpack": ["**/node_modules/sharp/**/*", "**/node_modules/@img/**/*"], + "directories": { + "output": "release/${version}" + }, + "files": ["dist-electron", "dist"], + "mac": { + "gatekeeperAssess": false, + "hardenedRuntime": true, + "entitlements": "scripts/entitlements.mac.plist", + "entitlementsInherit": "scripts/entitlements.mac.plist", + "identity": "Yika(Beijing)Technology Co., Ltd.", + "artifactName": "${productName}-${version}-${arch}.${ext}", + "target": [ + { + "target": "default", + "arch": ["x64", "arm64"] + } + ] + }, + "win": { + "target": [ + { + "target": "nsis", + "arch": ["x64"] + } + ], + "artifactName": "${productName}-${version}.${ext}" + }, + "nsis": { + "oneClick": false, + "perMachine": false, + "allowToChangeInstallationDirectory": true, + "deleteAppDataOnUninstall": false + }, + "publish": { + "provider": "generic", + "channel": "latest", + "url": "https://ylzsfile.yikart.cn/att/" + }, + "extraResources": [ + { + "from": "public/assets", + "to": "assets", + "filter": ["**/*"] + } + ] +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/index.ts new file mode 100644 index 000000000..c05715413 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/index.ts @@ -0,0 +1,180 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:22:03 + * @LastEditTime: 2025-03-18 22:53:17 + * @LastEditors: nevin + * @Description: 数据库 + */ +import { DataSource } from 'typeorm'; +import { AccountModel } from './models/account'; +import { UserModel } from './models/user'; +import { PubRecordModel } from './models/pubRecord'; +import { VideoModel } from './models/video'; +import * as migrations from './migrations'; +import path from 'path'; +import { app } from 'electron'; +import fs from 'fs/promises'; +import { logger } from '../global/log'; +import { AutoRunModel } from './models/autoRun'; +import { AutoRunRecordModel } from './models/autoRunRecord'; +import { ImgTextModel } from './models/imgText'; +import { ReplyCommentRecordModel } from './models/replyCommentRecord'; +import { InteractionRecordModel } from './models/interactionRecord'; +import { AccountGroupModel } from './models/accountGroup'; +import { defaultAccountGroupId } from '../../commont/AccountEnum'; + +const configPath = app.getPath('userData'); +const database = path.join(configPath, 'database.sqlite'); +logger.log('att database path:', database); + +export const AppDataSource = new DataSource({ + type: 'better-sqlite3', // 设定链接的数据库类型 + database, // 数据库存放地址 + synchronize: true, // 确保每次运行应用程序时实体都将与数据库同步 + logging: false, // 日志,默认在控制台中打印,数组列举错误类型枚举 + entities: [ + AccountModel, + UserModel, + PubRecordModel, + VideoModel, + AutoRunModel, + AutoRunRecordModel, + ImgTextModel, + ReplyCommentRecordModel, + InteractionRecordModel, + AccountGroupModel, + ], // 实体或模型表 + migrations: Object.values(migrations), // 迁移类 + migrationsRun: true, // 确保在连接时自动运行迁移 +}); + +// 数据库默认数据添加 +async function sqliteDefaultDataInit() { + // 添加用户组 【默认列表】 + const accountGroupRepository = AppDataSource.getRepository(AccountGroupModel); + const accountGroup = await accountGroupRepository.findOne({ + where: { id: defaultAccountGroupId }, + }); + if (!accountGroup) { + await accountGroupRepository.save({ + id: defaultAccountGroupId, + name: '默认列表', + rank: 0, + }); + } +} + +/** + * 初始化sqlite3数据库 + */ +export async function initSqlite3Db() { + if (!AppDataSource.isInitialized) { + try { + await AppDataSource.initialize(); + await sqliteDefaultDataInit(); + // await AppDataSource.runMigrations(); // 上面已经有自动迁移 + return true; + } catch (error) { + logger.error('Error during database initialization:', error); + return false; + } + } + return true; +} + +/** + * 导出数据库到SQL文件 + * @param filePath 导出文件路径 + */ +export async function exportDatabase(filePath: string): Promise { + try { + if (!AppDataSource.isInitialized) { + logger.error('Database is not initialized'); + throw new Error('Database is not initialized'); + } + + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + + // 获取所有表的数据 + const tables = AppDataSource.entityMetadatas.map( + (entity) => entity.tableName, + ); + let sqlContent = ''; + + for (const table of tables) { + const records = await queryRunner.query(`SELECT * FROM ${table}`); + if (records.length > 0) { + sqlContent += `-- Table: ${table}\n`; + for (const record of records) { + const columns = Object.keys(record).join(', '); + const values = Object.values(record) + .map((value) => { + if (value === null) return 'NULL'; + if (typeof value === 'string') + return `'${value.replace(/'/g, "''")}'`; + return value; + }) + .join(', '); + sqlContent += `INSERT INTO ${table} (${columns}) VALUES (${values});\n`; + } + sqlContent += '\n'; + } + } + + await fs.writeFile(filePath, sqlContent, 'utf8'); + await queryRunner.release(); + } catch (error) { + logger.error('Failed to export database:', error); + throw error; + } +} + +/** + * 从SQL文件导入数据 + * @param filePath SQL文件路径 + */ +export async function importDatabase(filePath: string): Promise { + try { + if (!AppDataSource.isInitialized) { + throw new Error('Database is not initialized'); + } + + const sqlContent = await fs.readFile(filePath, 'utf8'); + const queryRunner = AppDataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + // 清空所有表 + const tables = AppDataSource.entityMetadatas.map( + (entity) => entity.tableName, + ); + for (const table of tables) { + await queryRunner.query(`DELETE FROM ${table}`); + } + + // 执行SQL语句 + const statements = sqlContent + .split('\n') + .filter((line) => line.trim() && !line.startsWith('--')) + .join('\n') + .split(';') + .filter((statement) => statement.trim()); + + for (const statement of statements) { + await queryRunner.query(statement); + } + + await queryRunner.commitTransaction(); + } catch (error) { + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } catch (error) { + logger.error('Failed to import database:', error); + throw error; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707791500-InitialMigration.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707791500-InitialMigration.ts new file mode 100644 index 000000000..455bfba82 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707791500-InitialMigration.ts @@ -0,0 +1,150 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialMigration1707791500000 implements MigrationInterface { + name = 'InitialMigration1707791500000'; + + async up(queryRunner: QueryRunner): Promise { + // Create user table + await queryRunner.query(` + CREATE TABLE "user" ( + "id" varchar PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "phone" varchar NOT NULL, + "loginTime" datetime NOT NULL, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP) + ) + `); + + // Create account table + await queryRunner.query(` + CREATE TABLE "account" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "userId" varchar NOT NULL, + "type" varchar NOT NULL, + "loginCookie" varchar NOT NULL, + "token" varchar, + "loginTime" datetime, + "uid" varchar NOT NULL, + "account" varchar NOT NULL, + "avatar" varchar NOT NULL, + "nickname" varchar NOT NULL, + "fansCount" integer NOT NULL DEFAULT 0, + "readCount" integer NOT NULL DEFAULT 0, + "likeCount" integer NOT NULL DEFAULT 0, + "collectCount" integer NOT NULL DEFAULT 0, + "forwardCount" integer NOT NULL DEFAULT 0, + "commentCount" integer NOT NULL DEFAULT 0, + "lastStatsTime" datetime, + "workCount" integer NOT NULL DEFAULT 0, + "income" bigint NOT NULL DEFAULT 0, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_account_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE + ) + `); + + // Create pubRecord table + await queryRunner.query(` + CREATE TABLE "pubRecord" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "userId" varchar NOT NULL, + "type" varchar NOT NULL, + "title" varchar, + "desc" varchar NOT NULL, + "videoPath" varchar NOT NULL, + "coverPath" varchar NOT NULL, + "publishTime" datetime NOT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_pubRecord_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE + ) + `); + + // Create video table + await queryRunner.query(` + CREATE TABLE "video" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "userId" varchar NOT NULL, + "pubRecordId" integer NOT NULL, + "accountId" integer NOT NULL, + "title" varchar, + "desc" varchar, + "videoPath" varchar, + "coverPath" varchar, + "lastStatsTime" datetime, + "dataId" varchar, + "type" varchar NOT NULL, + "publishTime" datetime, + "otherInfo" json, + "failMsg" varchar, + "status" tinyint NOT NULL DEFAULT 0, + "readCount" integer NOT NULL DEFAULT 0, + "likeCount" integer NOT NULL DEFAULT 0, + "collectCount" integer NOT NULL DEFAULT 0, + "forwardCount" integer NOT NULL DEFAULT 0, + "commentCount" integer NOT NULL DEFAULT 0, + "income" bigint NOT NULL DEFAULT 0, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_video_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_video_pubRecord" FOREIGN KEY ("pubRecordId") REFERENCES "pubRecord" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_video_account" FOREIGN KEY ("accountId") REFERENCES "account" ("id") ON DELETE CASCADE + ) + `); + + // Create account_stats table + await queryRunner.query(` + CREATE TABLE "account_stats" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "userId" varchar NOT NULL, + "accountId" integer NOT NULL, + "type" varchar NOT NULL, + "readCount" integer NOT NULL DEFAULT 0, + "likeCount" integer NOT NULL DEFAULT 0, + "collectCount" integer NOT NULL DEFAULT 0, + "forwardCount" integer NOT NULL DEFAULT 0, + "commentCount" integer NOT NULL DEFAULT 0, + "fansCount" integer NOT NULL DEFAULT 0, + "income" bigint NOT NULL DEFAULT 0, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_account_stats_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_account_stats_account" FOREIGN KEY ("accountId") REFERENCES "account" ("id") ON DELETE CASCADE + ) + `); + + // Create video_stats table + await queryRunner.query(` + CREATE TABLE "video_stats" ( + "id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, + "userId" varchar NOT NULL, + "videoId" integer NOT NULL, + "accountId" integer NOT NULL, + "type" varchar NOT NULL, + "readCount" integer NOT NULL DEFAULT 0, + "likeCount" integer NOT NULL DEFAULT 0, + "collectCount" integer NOT NULL DEFAULT 0, + "forwardCount" integer NOT NULL DEFAULT 0, + "commentCount" integer NOT NULL DEFAULT 0, + "income" bigint NOT NULL DEFAULT 0, + "createTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + "updateTime" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), + CONSTRAINT "FK_video_stats_user" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_video_stats_video" FOREIGN KEY ("videoId") REFERENCES "video" ("id") ON DELETE CASCADE, + CONSTRAINT "FK_video_stats_account" FOREIGN KEY ("accountId") REFERENCES "account" ("id") ON DELETE CASCADE + ) + `); + } + + async down(queryRunner: QueryRunner): Promise { + // Drop tables in reverse order to handle foreign key constraints + await queryRunner.query(`DROP TABLE "video_stats"`); + await queryRunner.query(`DROP TABLE "account_stats"`); + await queryRunner.query(`DROP TABLE "video"`); + await queryRunner.query(`DROP TABLE "pubRecord"`); + await queryRunner.query(`DROP TABLE "account"`); + await queryRunner.query(`DROP TABLE "user"`); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707801786-AddAccountStatus.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707801786-AddAccountStatus.ts new file mode 100644 index 000000000..2ad1d8fde --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/1707801786-AddAccountStatus.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAccountStatus1707801786000 implements MigrationInterface { + name = 'AddAccountStatus1707801786000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "account" ADD COLUMN "status" tinyint NOT NULL DEFAULT 0`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "account" DROP COLUMN "status"`); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/index.ts new file mode 100644 index 000000000..54f9b672c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/migrations/index.ts @@ -0,0 +1,2 @@ +export { InitialMigration1707791500000 } from './1707791500-InitialMigration'; +export { AddAccountStatus1707801786000 } from './1707801786-AddAccountStatus'; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/account.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/account.ts new file mode 100644 index 000000000..e2ed01829 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/account.ts @@ -0,0 +1,123 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-02-17 12:24:49 + * @LastEditors: nevin + * @Description: 平台账号 + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { + AccountStatus, + PlatType, + XhsAccountAbnormal, +} from '../../../commont/AccountEnum'; + +@Entity({ name: 'account' }) +export class AccountModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ + type: 'varchar', + enum: PlatType, + nullable: true, + comment: '平台类型', + }) + type!: PlatType; + + @Column({ type: 'varchar', nullable: false, comment: '登录cookie' }) + loginCookie!: string; + + @Column({ + type: 'varchar', + nullable: true, + comment: '其他token 目前抖音用', + default: '', + }) + token?: string; + + @Column({ + type: 'datetime', + nullable: true, + comment: '登录时间', + }) + loginTime?: Date; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + uid!: string; + + // 账号 + @Column({ type: 'varchar', nullable: false, comment: '账号' }) + account!: string; + + // 头像 + @Column({ type: 'varchar', nullable: false, comment: '头像' }) + avatar!: string; + + // 昵称 + @Column({ type: 'varchar', nullable: false, comment: '昵称' }) + nickname!: string; + + // 粉丝数量 + @Column({ type: 'int', nullable: false, comment: '粉丝数量', default: 0 }) + fansCount!: number; + + // 阅读数量 + @Column({ type: 'int', nullable: false, comment: '总阅读数量', default: 0 }) + readCount!: number; + + // 点赞数量 + @Column({ type: 'int', nullable: false, comment: '总点赞数量', default: 0 }) + likeCount!: number; + + // 收藏数量 + @Column({ type: 'int', nullable: false, comment: '总收藏数量', default: 0 }) + collectCount!: number; + + // 转发数量 + @Column({ type: 'int', nullable: false, comment: '总转发数量', default: 0 }) + forwardCount!: number; + + // 评论数量 + @Column({ type: 'int', nullable: false, comment: '总评论数量', default: 0 }) + commentCount!: number; + + @Column({ type: 'datetime', nullable: true, comment: '最后统计时间' }) + lastStatsTime?: Date; + + // 作品数量 + @Column({ type: 'int', nullable: false, comment: '作品数量', default: 0 }) + workCount?: number; + + // 收益 + @Column({ type: 'bigint', nullable: false, comment: '收益', default: 0 }) + income?: number; + + // 账号异常状态,异常状态无法发视频 + @Column({ type: 'json', nullable: true }) + abnormalStatus?: { + [PlatType.Xhs]: XhsAccountAbnormal; + }; + + // 登录状态,判断是否失效 + @Column({ + type: 'tinyint', + nullable: false, + comment: '登录状态,用于判断是否失效', + default: AccountStatus.USABLE, + }) + status?: AccountStatus; + + // 关联组 + @Column({ + type: 'int', + nullable: false, + comment: '关联组,与 AccountGroupModel表 id关联', + default: 1, + }) + groupId!: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/accountGroup.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/accountGroup.ts new file mode 100644 index 000000000..8d8113063 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/accountGroup.ts @@ -0,0 +1,42 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; + +/** + * 账户组 + * 与账户表关联 + */ +@Entity({ name: 'accountGroup' }) +export class AccountGroupModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ + type: 'varchar', + nullable: false, + comment: '组名称', + }) + name!: string; + + @Column({ + type: 'varchar', + nullable: true, + comment: '组代理IP', + }) + proxyIp?: string; + + @Column({ + type: 'int', + nullable: false, + comment: '组排序', + default: 1, + }) + rank!: number; + + @Column({ + type: 'boolean', + nullable: false, + comment: '是否启用代理', + default: true, + }) + proxyOpen!: boolean; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRun.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRun.ts new file mode 100644 index 000000000..5e4f728be --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRun.ts @@ -0,0 +1,66 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-03-19 19:31:29 + * @LastEditors: nevin + * @Description: 自动任务 autoRun + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; + +// 状态 进行中 暂停 删除 +export enum AutoRunStatus { + DOING = 2, // 进行中 + PAUSE = 3, // 暂停 + DELETE = 4, // 删除 +} + +export enum AutoRunType { + ReplyComment = 1, // 回复评论 +} + +@Entity({ name: 'autoRun' }) +export class AutoRunModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ type: 'int', nullable: false, comment: '账号id,对应account表id' }) + accountId!: number; + + @Column({ + type: 'text', + nullable: false, + comment: '对应数据的数据内容 JSON字符串', + }) + data!: string; + dataInfo?: Record; // 解析后的数据 + + @Column({ type: 'int', nullable: false, comment: '执行次数', default: 0 }) + runCount!: number; + + @Column({ + type: 'tinyint', + nullable: false, + comment: '自动程序状态', + default: AutoRunStatus.DOING, + }) + status!: AutoRunStatus; + + @Column({ + type: 'tinyint', + nullable: false, + comment: '类型', + }) + type!: AutoRunType; + + @Column({ + type: 'varchar', + nullable: false, + comment: + '周期类型 天 day-22 (例:每天22时) 周 week-2 (例:每周周二,周日0) 月 month-22 (例:每月22号)', + }) + cycleType!: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRunRecord.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRunRecord.ts new file mode 100644 index 000000000..ba657c0d9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/autoRunRecord.ts @@ -0,0 +1,55 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-03-23 09:27:54 + * @LastEditors: nevin + * @Description: 自动评论 autoComment + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { AutoRunType } from './autoRun'; + +// 状态 进行中 失败 完成 +export enum AutoRunRecordStatus { + DOING = 1, // 进行中 + FAIL = 2, // 失败 + SUCCESS = 3, // 完成 +} + +@Entity({ name: 'autoRunRecord' }) +export class AutoRunRecordModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'int', nullable: false, comment: ' 自动任务id' }) + autoRunId!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ + type: 'tinyint', + nullable: false, + comment: '自动程序运行状态', + default: AutoRunRecordStatus.DOING, + }) + status!: AutoRunRecordStatus; + + @Column({ + type: 'tinyint', + nullable: false, + comment: '类型', + }) + type!: AutoRunType; + + @Column({ + type: 'varchar', + nullable: false, + comment: + '周期类型 天 day-22 (例:每天22时) 周 week-2 (例:每周周二,周日0) 月 month-22 (例:每月22号)', + }) + cycleType!: string; + + @Column({ type: 'varchar', nullable: true, comment: '记录描述' }) + record?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/imgText.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/imgText.ts new file mode 100644 index 000000000..fcf0156fb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/imgText.ts @@ -0,0 +1,15 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-02-12 18:32:28 + * @LastEditors: nevin + * @Description: 图文发布记录 + */ +import { Column, Entity } from 'typeorm'; +import { WorkData } from './workData'; + +@Entity({ name: 'imgText' }) +export class ImgTextModel extends WorkData { + @Column({ type: 'json', nullable: false, comment: '多个图片的路径' }) + imagesPath!: string[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/interactionRecord.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/interactionRecord.ts new file mode 100644 index 000000000..bcbb24cdd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/interactionRecord.ts @@ -0,0 +1,51 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-03-23 09:27:54 + * @LastEditors: nevin + * @Description: 互动记录记录 interactionRecord + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { PlatType } from '../../../commont/AccountEnum'; + +@Entity({ name: 'interactionRecord' }) +export class InteractionRecordModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ type: 'int', nullable: false, comment: '账号id,对应account表id' }) + accountId!: number; + + @Column({ + type: 'varchar', + enum: PlatType, + nullable: true, + comment: '平台类型', + }) + type!: PlatType; + + @Column({ type: 'varchar', nullable: false, comment: '作品Id' }) + worksId!: string; + + @Column({ type: 'varchar', nullable: true, comment: '作品标题' }) + worksTitle?: string; + + @Column({ type: 'varchar', nullable: true, comment: '评论备注' }) + commentRemark?: string; + + @Column({ type: 'varchar', nullable: true, comment: '封面' }) + worksCover?: string; + + @Column({ type: 'varchar', nullable: false, comment: '评论内容' }) + commentContent!: string; + + @Column({ type: 'tinyint', nullable: false, comment: '是否点赞' }) + isLike!: 0 | 1; + + @Column({ type: 'tinyint', nullable: false, comment: '是否收藏' }) + isCollect!: 0 | 1; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/pubRecord.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/pubRecord.ts new file mode 100644 index 000000000..a888c70ad --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/pubRecord.ts @@ -0,0 +1,68 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-02-05 17:00:23 + * @LastEditors: nevin + * @Description: 发布记录 + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { PubStatus, PubType } from '../../../commont/publish/PublishEnum'; + +@Entity({ name: 'pubRecord' }) +export class PubRecordModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ + type: 'varchar', + enum: PubType, + nullable: false, + comment: '发布类型', + }) + type!: PubType; + + // 标题 视频发布没有标题 + @Column({ type: 'varchar', nullable: false, comment: '标题' }) + title?: string; + + // 简介 + @Column({ type: 'varchar', nullable: false, comment: '简介' }) + desc!: string; + + // 视频路径 + @Column({ type: 'varchar', nullable: true, comment: '视频路径' }) + videoPath?: string; + + // 定时发布日期 + @Column({ type: 'datetime', nullable: true, comment: '定时发布日期' }) + timingTime?: Date; + + // 封面路径 + @Column({ + type: 'varchar', + nullable: false, + comment: '封面路径,展示给前台用', + }) + coverPath!: string; + + // 通用封面路径 + @Column({ type: 'varchar', nullable: true, comment: '通用封面路径' }) + commonCoverPath?: string; + + // 发布时间 + @Column({ type: 'datetime', nullable: false, comment: '发布时间' }) + publishTime!: Date; + + // 状态 + @Column({ + type: 'tinyint', + nullable: false, + comment: '状态 0=未发布/草稿 1=已发布', + default: PubStatus.UNPUBLISH, + }) + status!: PubStatus; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/replyCommentRecord.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/replyCommentRecord.ts new file mode 100644 index 000000000..42e32583e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/replyCommentRecord.ts @@ -0,0 +1,39 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-03-23 09:27:54 + * @LastEditors: nevin + * @Description: 回复评论的记录 replyCommentRecord + */ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { PlatType } from '../../../commont/AccountEnum'; + +@Entity({ name: 'replyCommentRecord' }) +export class ReplyCommentRecordModel extends TempModel { + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id!: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + @Column({ type: 'int', nullable: false, comment: '账号id,对应account表id' }) + accountId!: number; + + @Column({ + type: 'varchar', + enum: PlatType, + nullable: true, + comment: '平台类型', + }) + type!: PlatType; + + @Column({ type: 'varchar', nullable: false, comment: '评论Id' }) + commentId!: string; + + @Column({ type: 'varchar', nullable: false, comment: '评论内容' }) + commentContent!: string; + + @Column({ type: 'varchar', nullable: false, comment: '回复的内容' }) + replyContent!: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/temp.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/temp.ts new file mode 100644 index 000000000..11bac956f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/temp.ts @@ -0,0 +1,28 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-01-21 14:48:45 + * @LastEditors: nevin + * @Description: 通用模板 + */ +import { Column } from 'typeorm'; + +export class TempModel { + // 创建时间 + @Column({ + type: 'datetime', + nullable: false, + comment: '创建时间', + default: () => 'CURRENT_TIMESTAMP', + }) + createTime?: Date; + + // 更新时间 + @Column({ + type: 'datetime', + nullable: false, + comment: '更新时间', + default: () => 'CURRENT_TIMESTAMP', + }) + updateTime?: Date; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/user.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/user.ts new file mode 100644 index 000000000..be2410504 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/user.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-01-21 21:35:35 + * @LastEditors: nevin + * @Description: 平台用户 + */ +import { Entity, PrimaryColumn, Column } from 'typeorm'; +import { TempModel } from './temp'; + +@Entity({ name: 'user' }) +export class UserModel extends TempModel { + @PrimaryColumn({ type: 'varchar', nullable: false, comment: '用户id' }) // 主键 + id!: string; + + @Column({ type: 'varchar', nullable: false, comment: '用户名称' }) + name!: string; + + @Column({ type: 'varchar', nullable: false, comment: '用户手机号' }) + phone!: string; + + @Column({ type: 'varchar', nullable: true, comment: '用户微信openid' }) + wxOpenId!: string; + + @Column({ type: 'datetime', nullable: false, comment: '登录时间' }) + loginTime!: Date; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/video.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/video.ts new file mode 100644 index 000000000..2aeb3da39 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/video.ts @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-02-12 18:32:28 + * @LastEditors: nevin + * @Description: 视频发布记录 + */ +import { Entity, Column } from 'typeorm'; +import { + ILableValue as ILableValueP, + WorkData, + DiffParmasType as DiffParmasTypeP, +} from './workData'; + +export type ILableValue = ILableValueP; + +export type DiffParmasType = DiffParmasTypeP; + +@Entity({ name: 'video' }) +export class VideoModel extends WorkData { + // 视频路径 + @Column({ type: 'varchar', nullable: false, comment: '视频路径' }) + videoPath!: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/workData.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/workData.ts new file mode 100644 index 000000000..b5574e673 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/db/models/workData.ts @@ -0,0 +1,202 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:24:16 + * @LastEditTime: 2025-02-12 18:31:21 + * @LastEditors: nevin + * @Description: 视频发布记录 不是数据表实体 + */ +import { Column, PrimaryGeneratedColumn } from 'typeorm'; +import { TempModel } from './temp'; +import { PlatType } from '../../../commont/AccountEnum'; +import type { + CookiesType, + ILocationDataItem, + WxSphEvent, +} from '../../main/plat/plat.type'; +import { + PubStatus, + VisibleTypeEnum, +} from '../../../commont/publish/PublishEnum'; +import { DeclarationDouyin } from '../../../commont/plat/douyin/common.douyin'; + +// 包含一个name和一个value的对象 +export interface ILableValue { + label: string; + value: string | number; +} + +/** + * 不同平台的差异化参数 + * 每个平台有相同点,有不同点,这不同点都在这个参数下集合 + */ +export type DiffParmasType = { + [PlatType.Xhs]?: {}; + [PlatType.Douyin]?: { + // 申请关联的热点 + hotPoint?: ILableValue; + // 申请关联的活动 + activitys?: ILableValue[]; + // 自主声明 + selfDeclare?: DeclarationDouyin; + }; + [PlatType.WxSph]?: { + // 是否为原创 + isOriginal?: boolean; + // 扩展链接 + extLink?: string; + // 活动 + activity?: WxSphEvent; + }; + [PlatType.KWAI]?: {}; +}; + +export type WorkDataModel = WorkData; + +export class WorkData extends TempModel { + // 数据唯一ID + @Column({ type: 'varchar', nullable: true, comment: '数据唯一ID' }) + dataId?: string; + + @PrimaryGeneratedColumn({ type: 'int', comment: 'id' }) + id?: number; + + @Column({ type: 'varchar', nullable: false, comment: '用户id' }) + userId!: string; + + // 最后统计时间 + @Column({ type: 'datetime', nullable: true, comment: '最后统计时间' }) + lastStatsTime?: Date; + + // 预览地址,这个值是发布完成手动拼接的 + @Column({ type: 'varchar', nullable: true, comment: '预览地址' }) + previewVideoLink?: string; + + @Column({ + type: 'int', + nullable: false, + comment: '发布记录id,对应PubRecord表id', + }) + pubRecordId!: number; + + @Column({ type: 'int', nullable: false, comment: '账号id,对应account表id' }) + accountId!: number; + + @Column({ + type: 'varchar', + enum: PlatType, + nullable: false, + comment: '平台类型', + }) + type!: PlatType; + + // 发布时间 + @Column({ type: 'datetime', nullable: true, comment: '发布时间' }) + publishTime?: Date; + + // 其他信息 + @Column({ type: 'json', nullable: true, comment: '其他信息' }) + otherInfo?: Record; + + // 失败原因 + @Column({ + type: 'varchar', + nullable: true, + comment: '发布失败原因(如果失败)', + }) + failMsg?: string; + + // 状态 + @Column({ + type: 'tinyint', + nullable: false, + comment: '状态 0 未发布/草稿 1 已发布 2=发布失败 3=部分成功 4=审核中', + default: PubStatus.UNPUBLISH, + }) + status!: PubStatus; + + // 阅读数量 + @Column({ type: 'int', nullable: false, comment: '阅读数量', default: 0 }) + readCount?: number; + + // 点赞数量 + @Column({ type: 'int', nullable: false, comment: '点赞数量', default: 0 }) + likeCount?: number; + + // 收藏数量 + @Column({ type: 'int', nullable: false, comment: '收藏数量', default: 0 }) + collectCount?: number; + + // 转发数量 + @Column({ type: 'int', nullable: false, comment: '转发数量', default: 0 }) + forwardCount?: number; + + // 评论数量 + @Column({ type: 'int', nullable: false, comment: '评论数量', default: 0 }) + commentCount?: number; + + // 收益(分) + @Column({ type: 'bigint', nullable: false, comment: '收益', default: 0 }) + income?: number; + + // 以下为发布需要的参数 -------------------------------------------------------------------- + + // 标题 + @Column({ type: 'varchar', nullable: true, comment: '标题' }) + title?: string; + + // 简介,简介中不该包含话题,如果有需要每个平台再自己做处理。 + @Column({ type: 'varchar', nullable: true, comment: '简介' }) + desc?: string; + + // 封面路径,机器的本地路径 + @Column({ type: 'varchar', nullable: true, comment: '封面路径' }) + coverPath?: string; + + // 合集 + @Column({ type: 'json', nullable: true, comment: '合集' }) + mixInfo?: ILableValue; + + // 话题 格式:['话题1', '话题2'],不该包含 ‘#’ + @Column({ type: 'json', nullable: true, comment: '话题', default: '[]' }) + topics!: string[]; + + // 位置 + @Column({ type: 'json', nullable: true, comment: '位置' }) + location?: ILocationDataItem; + + /** + * 差异化参数 + * 所有平台有通用参数,如:标题、话题、简介 + * 也有每个平台自己独有的参数,如:抖音活动奖励、抖音热点、视频号声明原创 + */ + @Column({ + type: 'json', + nullable: true, + comment: '不同平台的差异化参数', + default: '{}', + }) + diffParams?: DiffParmasType; + + // 可见性,作品的查看权限 + @Column({ + type: 'tinyint', + nullable: false, + comment: '可见性', + default: VisibleTypeEnum.Public, + }) + visibleType?: VisibleTypeEnum; + + // 定时发布日期 + @Column({ type: 'datetime', nullable: true, comment: '定时发布日期' }) + timingTime?: Date; + + // @用户 + @Column({ type: 'json', nullable: true, comment: '@用户数组', default: '[]' }) + mentionedUserInfo!: ILableValue[]; + + // 以下参数通过外部赋值,不存储在数据库中 + cookies?: CookiesType; + // 这个参数从 account 中赋值,这是从创作者中心的localStorage中获取的参数 + token?: string; + proxyIp?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/electron-env.d.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/electron-env.d.ts new file mode 100644 index 000000000..2c660cf27 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/electron-env.d.ts @@ -0,0 +1,23 @@ +/// + +declare namespace NodeJS { + interface ProcessEnv { + VSCODE_DEBUG?: 'true'; + /** + * The built directory structure + * + * ```tree + * ├─┬ dist-electron + * │ ├─┬ main + * │ │ └── index.js > Electron-Main + * │ └─┬ preload + * │ └── index.mjs > Preload-Scripts + * ├─┬ dist + * │ └── index.html > Electron-Renderer + * ``` + */ + APP_ROOT: string; + /** /dist/ or /public/ */ + VITE_PUBLIC: string; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/cache.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/cache.ts new file mode 100644 index 000000000..8e260735b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/cache.ts @@ -0,0 +1,53 @@ +import NodeCache from 'node-cache'; + +class Cache { + private static instance: Cache; + private cache: NodeCache; + + private constructor() { + this.cache = new NodeCache(); + } + + public static getInstance(): Cache { + if (!Cache.instance) Cache.instance = new Cache(); + return Cache.instance; + } + + /** + * 缓存数据 + * @param key 缓存key + * @param value 缓存值 + * @param ttl 缓存时间 秒 + */ + public setCache(key: string, value: any, ttl?: number) { + if (ttl) { + this.cache.set(key, value, ttl); + } else { + this.cache.set(key, value); + } + } + + public getCache(key: string) { + return this.cache.get(key); + } + + public delCache(key: string) { + return this.cache.del(key); + } + + public clearCache() { + return this.cache.flushAll(); + } + + // 更改TTL + public updateCacheTTL(key: string, ttl: number) { + this.cache.ttl(key, ttl); + } + + // 设置多个缓存 + public setMultiCache(list: { key: string; val: any; ttl?: number }[]) { + this.cache.mset(list); + } +} + +export const GlobleCache = Cache.getInstance(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/event.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/event.ts new file mode 100644 index 000000000..e35cb174f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/event.ts @@ -0,0 +1,10 @@ +/* + * @Author: nevin + * @Date: 2025-02-06 19:15:58 + * @LastEditTime: 2025-03-19 14:26:43 + * @LastEditors: nevin + * @Description: 全局事件 + */ +import { EventEmitter } from 'events'; + +export const EtEvent = new EventEmitter(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/log.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/log.ts new file mode 100644 index 000000000..b82c383e0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/log.ts @@ -0,0 +1,82 @@ +/* + * @Author: nevin + * @Date: 2025-02-10 22:20:15 + * @LastEditTime: 2025-02-22 19:21:08 + * @LastEditors: nevin + * @Description: 日志组件 + */ +import * as path from 'path'; +import { app, ipcMain } from 'electron'; +import fs from 'fs'; + +import log from 'electron-log/main'; + +// 创建logs目录并配置日志路径 +const logDirectory = path.join(app.getPath('userData'), 'logs'); + +log.transports.file.level = 'info'; +log.transports.file.maxSize = 10 * 1024 * 1024; // 10MB +log.transports.file.resolvePathFn = () => { + return path.join( + logDirectory, + `${new Date().toISOString().slice(0, 10)}.log`, + ); +}; // 按天生成日志 + +log.initialize(); + +console.log = log.log; +console.error = log.error; + +export const logger = log; + +export default log; + +// 导出路径 +export const logPath = logDirectory; + +/** + * 获取近N天的文件路径列表 + * @param days + * @returns + */ +export function getLogFilePaths(days: number): string[] { + const logFiles = fs.readdirSync(logDirectory); + const logFilePaths = logFiles + .filter((file) => file.endsWith('.log')) + .map((file) => path.join(logDirectory, file)) + .filter((filePath) => { + const fileStat = fs.statSync(filePath); + const fileDate = new Date(fileStat.mtime); + return fileDate >= new Date(Date.now() - days * 24 * 60 * 60 * 1000); + }); + + return logFilePaths; +} + +/** + * 清理一周前的日志 + */ +export function clearOldLogs() { + const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const logFiles = fs.readdirSync(logDirectory); + const logFilePaths = logFiles + .filter((file) => file.endsWith('.log')) + .map((file) => path.join(logDirectory, file)) + .filter((filePath) => { + const fileStat = fs.statSync(filePath); + return fileStat.mtime.getTime() < oneWeekAgo; + }); + + logFilePaths.forEach((filePath) => { + fs.unlinkSync(filePath); + }); +} + +/** + * 获取日志文件列表 + */ +ipcMain.handle('GLOBAL_LOG_GET_FLIES', async (event, days) => { + const res = getLogFilePaths(days || 7); + return res; +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/notice.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/notice.ts new file mode 100644 index 000000000..ab9de561b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/notice.ts @@ -0,0 +1,17 @@ +/* + * @Author: nevin + * @Date: 2025-03-24 23:18:06 + * @LastEditTime: 2025-03-24 23:19:06 + * @LastEditors: nevin + * @Description: 系统通知 + */ +import { Notification } from 'electron'; +export const sysNotice = (title: string, body: string) => + new Promise((resolve, reject) => { + if (!Notification.isSupported()) reject('当前系统不支持通知'); + + const options = typeof title === 'object' ? title : { title, body }; + const notification = new Notification(options); + notification.on('click', resolve); + notification.show(); + }); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/schedule.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/schedule.ts new file mode 100644 index 000000000..a6536c2e4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/schedule.ts @@ -0,0 +1,22 @@ +/* + * @Author: nevin + * @Date: 2025-02-13 21:04:23 + * @LastEditTime: 2025-02-13 21:12:22 + * @LastEditors: nevin + * @Description: schedule 定时任务 + */ +import schedule from 'node-schedule'; +export const scheduleJob = schedule; + +export const scheduleJobMap = new Map(); + +/** + * 删除任务 + */ +export function deleteScheduleJob(key: string) { + const job = scheduleJobMap.get(key); + if (job) { + job.cancel(); + scheduleJobMap.delete(key); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/store.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/store.ts new file mode 100644 index 000000000..f9ba70ab1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/store.ts @@ -0,0 +1,19 @@ +/* + * @Author: nevin + * @Date: 2025-02-21 21:10:01 + * @LastEditTime: 2025-02-21 21:12:28 + * @LastEditors: nevin + * @Description: 存储 + */ +import { ipcMain } from 'electron'; +import Store from 'electron-store'; + +export const store: any = new Store(); +// 定义ipcRenderer监听事件 +ipcMain.handle('setStore', (_, key, value) => { + store.set(key, value); +}); +ipcMain.handle('getStore', async (_, key) => { + const value = store.get(key); + return value || ''; +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/table.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/table.ts new file mode 100644 index 000000000..f5eb7aac5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/global/table.ts @@ -0,0 +1,49 @@ +/* + * @Author: nevin + * @Date: 2022-03-17 16:05:38 + * @LastEditors: nevin + * @LastEditTime: 2025-01-23 22:46:37 + * @Description: 表格状数据 + */ + +import { PubStatus, PubType } from '../../commont/publish/PublishEnum'; + +export interface CorrectQuery { + page_size: number; + page_no: number; +} + +export interface pubRecordListQuery { + time?: [string, string]; + status?: PubStatus; + type?: PubType; +} + +export interface CorrectResponse { + list: T[]; + total_count: number; + total_page: number; + page_size: number; + page_no: number; +} + +/** + * 返回分页数据 + * @param list 列表 + * @param totalCount 总数 + * @param page 分页参数 + * @returns 分页数据 + */ +export function backPageData( + list: T[], + totalCount: number, + page: CorrectQuery, +): CorrectResponse { + return { + list, + total_count: totalCount, + total_page: Math.ceil(totalCount / page.page_size), + page_size: page.page_size, + page_no: page.page_no, + }; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/BrowserWindowItem.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/BrowserWindowItem.ts new file mode 100644 index 000000000..add572c70 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/BrowserWindowItem.ts @@ -0,0 +1,58 @@ +import { webContents, WebContents } from 'electron'; +import { ICookieParams, ICreateBrowserWindowParams } from './browserWindow'; + +export default class BrowserWindowItem { + webViewId: number; + webview!: WebContents; + + constructor(webViewId: number) { + this.webViewId = webViewId; + } + + /** + * 创建 webview + * @param data + */ + async create(data: ICreateBrowserWindowParams) { + this.webview = webContents.fromId(this.webViewId)!; + if (!this.webview) + return console.error(`无法找到id为 ‘${this.webViewId}’ 的webview`); + + // this.webview.openDevTools(); + // this.webview.setWindowOpenHandler((data) => { + // console.log(data.url); + // return { + // action: 'deny', + // }; + // }); + if (data.cookieParams) { + await this.setCookies(data.cookieParams); + } + return true; + } + + /** + * 设置cookies + * @param cookieParams + */ + async setCookies(cookieParams: ICookieParams) { + for (const v of cookieParams.cookies) { + let url: string; + if (v.domain![0] === '.') { + url = `https://www.${v.domain?.substring(1, v.domain?.length)}`; + } else { + url = `https://${v.domain}`; + } + await this.webview.session.cookies.set({ + url: url, + name: v.name, + value: v.value, + domain: v.domain, + path: v.path, + secure: v.secure, + httpOnly: v.httpOnly, + }); + } + return true; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/browserWindow.d.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/browserWindow.d.ts new file mode 100644 index 000000000..1094d98b5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/browserWindow.d.ts @@ -0,0 +1,12 @@ +// 设置cookie参数 +export interface ICookieParams { + // 创建时是否设置cookies + cookies: Electron.Cookie[]; +} + +export interface ICreateBrowserWindowParams { + // webview 的id + webViewId: number; + // cookie参数,这个参数不为空会在创建的时候设置cookie + cookieParams?: ICookieParams; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/index.ts new file mode 100644 index 000000000..014baa95c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/BrowserWindow/index.ts @@ -0,0 +1,29 @@ +import { ICreateBrowserWindowParams } from './browserWindow'; +import BrowserWindowItem from './BrowserWindowItem'; + +class BrowserWindowController { + browserWindowMap: Map = new Map(); + + // 创建 BrowserWindow + createBrowserWindow(data: ICreateBrowserWindowParams) { + return new Promise(async (resolve) => { + try { + const browserWindowItem = new BrowserWindowItem(data.webViewId); + this.browserWindowMap.set(data.webViewId, browserWindowItem); + await browserWindowItem.create(data); + resolve(null); + } catch (e) { + console.error(e); + } + }); + } + + // 销毁 BrowserWindow + destroyBrowserWindow(webViewId: number) { + // @ts-ignore + this.browserWindowMap.get(webViewId)?.webview.destroy(); + this.browserWindowMap.delete(webViewId); + } +} + +export const browserWindowController = new BrowserWindowController(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/controller.ts new file mode 100644 index 000000000..6d6705c97 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/controller.ts @@ -0,0 +1,287 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-02-19 22:08:57 + * @LastEditors: nevin + * @Description: + */ +import { Controller, Et, Icp, Inject } from '../core/decorators'; +import { AccountService } from './service'; +import { getUserInfo } from '../user/comment'; +import platController from '../plat'; +import type { ICreateBrowserWindowParams } from './BrowserWindow/browserWindow'; +import { browserWindowController } from './BrowserWindow'; +import { AccountStatus, PlatType } from '../../../commont/AccountEnum'; +import { AccountModel } from '../../db/models/account'; +import windowOperate from '../../util/windowOperate'; +import { SendChannelEnum } from '../../../commont/UtilsEnum'; +import { AccountGroupModel } from '../../db/models/accountGroup'; +import { proxyCheck } from '../../plat/coomont'; + +@Controller() +export class AccountController { + @Inject(AccountService) + private readonly accountService!: AccountService; + + // 创建浏览器视图 + @Icp('ICP_ACCOUNT_CREATE_BROWSER_VIEW') + async createBrowserView( + event: Electron.IpcMainInvokeEvent, + data: ICreateBrowserWindowParams, + ): Promise { + await browserWindowController.createBrowserWindow(data); + } + + // 销毁浏览器视图 + @Icp('ICP_ACCOUNT_DESTROY_BROWSER_VIEW') + async destroyBrowserView( + event: Electron.IpcMainInvokeEvent, + webViewId: number, + ): Promise { + browserWindowController.destroyBrowserWindow(webViewId); + } + + /** + * 登录三方平台 + */ + @Icp('ICP_ACCOUNT_LOGIN') + async accountLogin( + event: Electron.IpcMainInvokeEvent, + pType: PlatType, + ): Promise { + const userInfo = getUserInfo(); + + const accountInfo = await platController.platlogin(pType); + if (!accountInfo) return null; + + accountInfo.status = AccountStatus.USABLE; + accountInfo.userId = userInfo.id; + + const account = await this.accountService.addOrUpdateAccount( + { + userId: userInfo.id, + type: pType, + uid: accountInfo?.uid || '', + }, + accountInfo, + ); + windowOperate.sendRenderMsg(SendChannelEnum.AccountLoginFinish, account); + // 保存账户信息 + return account; + } + + /** + * 账户登录检测-单个 + */ + @Icp('ICP_ACCOUNT_LOGIN_CHECK') + async checkAccountLogin( + event: Electron.IpcMainInvokeEvent, + pType: PlatType, + uid: string, + isSendEvent: boolean = true, + ): Promise { + const account = await this.accountService.checkAccountLoginCore(pType, uid); + if (isSendEvent) { + windowOperate.sendRenderMsg(SendChannelEnum.AccountLoginFinish, account); + } + return account; + } + + /** + * 账户登录检测-多个 + */ + @Icp('ICP_ACCOUNT_LOGIN_CHECK_MULTI') + async checkAccountLoginMulti( + event: Electron.IpcMainInvokeEvent, + checkAccounts: { + pType: PlatType; + uid: string; + }[], + ): Promise<(AccountModel | null)[]> { + const tasks: Promise[] = []; + + for (const { pType, uid } of checkAccounts) { + tasks.push(this.accountService.checkAccountLoginCore(pType, uid)); + } + const accounts = await Promise.all(tasks); + windowOperate.sendRenderMsg( + SendChannelEnum.AccountLoginFinish, + accounts[0], + accounts, + ); + return accounts; + } + + // 更新用户状态 + @Icp('ICP_ACCOUNT_UPDATE_STATUS') + async updateAccountStatus( + event: Electron.IpcMainInvokeEvent, + // 账户ID + id: number, + status: AccountStatus, + ) { + return this.accountService.updateAccountStatus(id, status); + } + + // 获取账户信息 + @Icp('ICP_ACCOUNT_GET_INFO') + async getAccountInfo( + event: Electron.IpcMainInvokeEvent, + data: { type: PlatType; uid: string }, + ): Promise { + const userInfo = getUserInfo(); + + const { type, uid } = data; + + const accountInfo = await this.accountService.getAccountInfo({ + type, + userId: userInfo.id, + uid, + }); + + // console.log('userInfouserInfo@@@:', accountInfo); + + return accountInfo; + } + + // 获取账户列表 + @Icp('ICP_ACCOUNT_GET_LIST') + async getAccountList(event: Electron.IpcMainInvokeEvent): Promise { + const userInfo = getUserInfo(); + return this.accountService.getAccounts(userInfo.id); + } + + // 获取账户列表(ids) + @Icp('ICP_ACCOUNT_GET_LIST_BY_IDS') + async getAccountListByIdsTcp( + event: Electron.IpcMainInvokeEvent, + ids: number[], + ): Promise { + return this.getAccountListByIds(ids); + } + // 获取账户列表(ids) + @Et('ET_ACCOUNT_GET_LIST_BY_IDS') + async getAccountListByIdsEt( + ids: number[], + callback: (p: AccountModel[]) => void, + ): Promise { + const accounts = await this.getAccountListByIds(ids); + callback(accounts); + } + async getAccountListByIds(ids: number[]) { + const userInfo = getUserInfo(); + if (!userInfo?.id) return []; + return this.accountService.getAccountListByIds(userInfo.id, ids); + } + + // 获取账户总数 + @Icp('ICP_ACCOUNT_GET_COUNT') + async getAccountCount(event: Electron.IpcMainInvokeEvent): Promise { + const userInfo = getUserInfo(); + return this.accountService.getAccountCount(userInfo.id); + } + + // 获取账户统计 + @Icp('ICP_ACCOUNT_STATISTICS') + async getAccountStatistics( + event: Electron.IpcMainInvokeEvent, + type?: PlatType, + ): Promise { + const userInfo = getUserInfo(); + return this.accountService.getAccountStatistics(userInfo.id, type); + } + + // 获取账户的看板数据 + @Icp('ICP_ACCOUNT_DASHBOARD') + async getDashboard( + event: Electron.IpcMainInvokeEvent, + id: number, + time?: any, + ) { + if (!id) return null; + + const account = await this.accountService.getAccountById(id); + if (!account) return null; + return this.accountService.getAccountDashboard(account, time); + } + + // 删除账户 + @Icp('ICP_ACCOUNTS_DELETE') + async deleteAccount( + event: Electron.IpcMainInvokeEvent, + ids: number[], + ): Promise { + const userInfo = getUserInfo(); + return this.accountService.deleteAccounts(ids, userInfo.id); + } + + // 修改账户的账户组 + @Icp('ICP_ACCOUNTS_EDIT_GROUP') + async accountEditGroup( + event: Electron.IpcMainInvokeEvent, + id: number, + groupId: number, + ): Promise { + return this.accountService.updateAccountInfo(id, { + groupId, + }); + } + + // 添加用户组数据 + @Icp('ICP_ACCOUNTS_GROUP_ADD') + async addAccountGroup( + event: Electron.IpcMainInvokeEvent, + data: Partial, + ): Promise { + return this.accountService.addAccountGroup(data); + } + // 获取用户组数据 + @Icp('ICP_ACCOUNTS_GROUP_GET') + async getAccountGroup(event: Electron.IpcMainInvokeEvent): Promise { + return this.accountService.getAccountGroup(); + } + // 删除用户组数据 + @Icp('ICP_ACCOUNTS_GROUP_DELETE') + async deleteAccountGroup( + event: Electron.IpcMainInvokeEvent, + id: number, + ): Promise { + return this.accountService.deleteAccountGroup(id); + } + // 编辑用户组数据 + @Icp('ICP_ACCOUNTS_GROUP_EDIT') + async editAccountGroup( + event: Electron.IpcMainInvokeEvent, + data: Partial, + ): Promise { + return this.accountService.editAccountGroup(data); + } + + // 代理地址有效性检测 + @Icp('ICP_ACCOUNTS_PROXY_CHECK') + async proxyCheck( + event: Electron.IpcMainInvokeEvent, + proxy: string, + ): Promise { + return await proxyCheck(proxy); + } + + @Et('ET_UP_ALL_ACCOUNT_STATISTICS') // 更新所有的账户的统计信息 + async updateAllAccountStatistics(id: number, status: number) { + const userInfo = getUserInfo(); + + const allAccountList = await this.accountService.getAccounts(userInfo.id); + for (const element of allAccountList) { + this.accountService.updateAccountStatistics( + element.id!, + element.fansCount, + element.readCount, + element.likeCount, + element.collectCount, + element.commentCount, + element.income!, + ); + } + await this.accountService.updateAccountStatus(id, status); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/module.ts new file mode 100644 index 000000000..071b50a8d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-02-06 19:13:05 + * @LastEditors: nevin + * @Description: + */ +import { Module } from '../core/decorators'; +import { AccountController } from './controller'; +import { AccountService } from './service'; + +@Module({ + controllers: [AccountController], + providers: [AccountService], +}) +export class AccountModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/service.ts new file mode 100644 index 000000000..bf7461432 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/account/service.ts @@ -0,0 +1,272 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 账户服务 + */ +import { AppDataSource } from '../../db'; +import { AccountModel } from '../../db/models/account'; +import { Injectable } from '../core/decorators'; +import { FindOptionsWhere, In, Repository } from 'typeorm'; +import { + AccountStatus, + PlatType, + defaultAccountGroupId, +} from '../../../commont/AccountEnum'; +import platController from '../plat/index'; +import { EtEvent } from '../../global/event'; +import { AccountGroupModel } from '../../db/models/accountGroup'; +import { getUserInfo } from '../user/comment'; + +@Injectable() +export class AccountService { + private accountRepository: Repository; + private accountGroupRepository: Repository; + + constructor() { + this.accountRepository = AppDataSource.getRepository(AccountModel); + this.accountGroupRepository = + AppDataSource.getRepository(AccountGroupModel); + } + + // 增加用户组数据 + async addAccountGroup(data: Partial) { + return await this.accountGroupRepository.save({ + ...data, + }); + } + // 获取用户组数据 + async getAccountGroup() { + return await this.accountGroupRepository.find(); + } + // 删除用户组数据 + async deleteAccountGroup(id: number) { + // 将删除的用户组下的账户账户的组id设置为默认组id + const accounts = await this.accountRepository.find({ + where: { groupId: id }, + }); + await this.accountRepository.update( + { id: In(accounts.map((v) => v.id)) }, + { + groupId: defaultAccountGroupId, + }, + ); + + // 删除 + return await this.accountGroupRepository.delete({ + id: id, + }); + } + // 修改用户组数据 + async editAccountGroup(data: Partial) { + return await this.accountGroupRepository.update({ id: data.id }, data); + } + + // 单个账号登录状态检测core + async checkAccountLoginCore(pType: PlatType, uid: string) { + const userInfo = getUserInfo(); + + const accountInfo = await this.getAccountInfo({ + type: pType, + userId: userInfo.id, + uid: uid, + }); + if (!accountInfo) return accountInfo; + // 取出cookie + if (!accountInfo.loginCookie) return accountInfo; + + const res = await platController + .platLoginCheck(pType, accountInfo) + .catch(() => ({ + online: false, + account: undefined, + })); + + await this.updateAccountInfo(accountInfo.id, { + status: res.online ? AccountStatus.USABLE : AccountStatus.DISABLE, + ...(res.online && typeof res.account === 'object' ? res.account : {}), + }); + const account = await this.getAccountById(accountInfo!.id!); + + return account || accountInfo; + } + + // 没有就添加有就更新cookie + async addOrUpdateAccount( + query: { + userId: string; + type: PlatType; + uid: string; + }, + account: Partial, + ): Promise { + const filter: FindOptionsWhere = { + userId: query.userId, + type: query.type, + uid: query.uid, + }; + const accountData = await this.accountRepository.findOne({ where: filter }); + account.loginTime = new Date(); + // 添加数据 + if (!accountData) { + const newAccount = await this.accountRepository.save(account); + // 上报账号添加事件 + EtEvent.emit('ET_TRACING_ACCOUNT_ADD', { + id: newAccount.id, + desc: '添加账户' + query.type, + }); + + return newAccount; + } + + // 更新数据 + await this.accountRepository.update(filter, account); + + return { + ...accountData, + ...account, + }; + } + + // 获取账户 + async getAccountById(id: number) { + return await this.accountRepository.findOne({ where: { id } }); + } + + // 获取账户信息 + async getAccountInfo(query: { + type: PlatType; + userId: string; + uid: string; + }) { + return await this.accountRepository.findOne({ where: query }); + } + + // 获取所有账户 + async getAccounts(userId?: string) { + if (!userId) { + const userInfo = getUserInfo(); + userId = userInfo.id; + } + return await this.accountRepository.find({ where: { userId } }); + } + + // 根据ID数组ids获取账户列表数组 + async getAccountListByIds(userId: string, ids: number[]) { + return await this.accountRepository.find({ + where: { + userId, + id: In(ids), + }, + }); + } + + /** + * 获取账户的统计信息 + * @param userId + * @param type + * @returns + */ + async getAccountStatistics( + userId: string, + type?: PlatType, + ): Promise<{ + accountTotal: number; + list: AccountModel[]; + fansCount?: number; + readCount?: number; + likeCount?: number; + collectCount?: number; + commentCount?: number; + income?: number; + }> { + const accountList = await this.accountRepository.find({ + where: { userId, ...(type && { type }) }, + }); + + const res = { + accountTotal: accountList.length, + list: accountList, + fansCount: 0, + }; + + for (const element of accountList) { + const ret = await platController.getStatistics(element).catch((err) => { + console.error(err); + }); + res.fansCount += ret?.fansCount || 0; + } + + return res; + } + + // 获取账户看板数据 + async getAccountDashboard(account: AccountModel, time?: [string, string]) { + return await platController.getDashboard(account, time); + } + + // 获取账户总数 + async getAccountCount(userId: string) { + return await this.accountRepository.count({ where: { userId } }); + } + + // 根据多个账户id查询账户信息 + async getAccountsByIds(ids: number[]) { + return await this.accountRepository.find({ + where: { id: In(ids) }, + }); + } + + // 更新粉丝数量 + async updateFansCount(userId: string, account: string, fansCount: number) { + return await this.accountRepository.update( + { userId, account }, + { fansCount: fansCount }, + ); + } + + // 获取用户的所有账户的总粉丝量 + async getUserFansCount(userId: string) { + const accounts = await this.accountRepository.find({ where: { userId } }); + return accounts.reduce((acc, cur) => acc + (cur.fansCount || 0), 0); + } + + // 删除多个账户 + async deleteAccounts(ids: number[], userId: string) { + return await this.accountRepository.delete({ + id: In(ids), + userId: userId, + }); + } + + // 更新用户状态 + async updateAccountStatus(id: number, status: number) { + await this.accountRepository.update(id, { status }); + return await this.accountRepository.findOne({ where: { id } }); + } + + // 更新用户信息 + async updateAccountInfo(id: number, data: Partial) { + return await this.accountRepository.update(id, data); + } + + // 更新账户的统计信息 + async updateAccountStatistics( + id: number, + fansCount: number, + readCount: number, + likeCount: number, + collectCount: number, + commentCount: number, + income: number, + ) { + return await this.accountRepository.update(id, { + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/index.ts new file mode 100644 index 000000000..4d14ebfc4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/index.ts @@ -0,0 +1,86 @@ +import { net } from 'electron'; +import { getUserToken } from '../user/comment'; + +export interface IRequestNetResult { + status: number; + headers: Record; + data: T; +} + +export interface IRequestNetParams { + headers?: any; + url: string; + body?: any; + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + isFile?: boolean; + isToken?: boolean; +} + +const netRequest = ({ + headers, + body, + method, + url, + isFile, + isToken, +}: IRequestNetParams): Promise> => { + return new Promise((resolve, reject) => { + const req = net.request({ + method: method || 'GET', + url: `${process.env.VITE_APP_URL}/${url}`, + }); + + // 设置请求头 + if (headers) { + Object.entries(headers).forEach(([key, value]) => { + // @ts-ignore + req.setHeader(key, value); + }); + } + + if (isToken) req.setHeader('Authorization', `Bearer ${getUserToken()}`); + + // 处理响应 + req.on('response', (response) => { + let data = ''; + response.on('data', (chunk) => { + data += chunk; + }); + + response.on('end', () => { + let parsedData: T; + try { + parsedData = JSON.parse(data); + } catch (e) { + parsedData = undefined as any; + console.log(e); + } + resolve({ + status: response.statusCode, + headers: response.headers, + data: parsedData, + }); + }); + }); + + // 错误处理 + req.on('error', (error) => { + reject(error); + }); + + if (isFile) { + req.setHeader('Content-Type', 'application/octet-stream'); + req.write(body); + } else { + // 发送请求体 + if (body) { + req.setHeader('Content-Type', 'application/json'); + req.write(typeof body === 'string' ? body : JSON.stringify(body)); + } + } + + req.end(); + }); +}; + +export default netRequest; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/taskApi.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/taskApi.ts new file mode 100644 index 000000000..287e45d4d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/taskApi.ts @@ -0,0 +1,59 @@ +import * as fs from 'fs'; // 新增文件系统导入 +import netRequest from '.'; +import FormData from 'form-data'; + + +export class TaskApi { + // 获取活动任务 + async getActivityTask() { + const res = await netRequest({ + method: 'GET', + url: 'tasks/list?page=1&pageSize=20&totalCount=0&type=interaction', + body: {}, + isToken: true, + }); + const { + status, + data: { data, code }, + } = res; + // console.log('---- getActivityTask ----', data); + if (status !== 200 && status !== 201) return ''; + if (!!code) return ''; + return data; + } + + + // 申请任务 + async applyTask(id: string, data: {account: string; + uid: string; + accountType: string; + }) { + console.log('------ applyTask ----', id, data); + const res = await netRequest({ + method: 'POST', + url: `tasks/apply/${id}`, + body: data, + isToken: true, + }); + console.log('------ applyTask res ----', res); + return res; + } + + + // 提交任务 + async submitTask(id: string, data: { + submissionUrl?: string; // 提交的结果,视频、文章或截图URL + screenshotUrls?: string[]; // 任务截图 + qrCodeScanResult?: string; // 二维码扫描结果 + }) { + const res = await netRequest({ + method: 'POST', + url: `tasks/submit/${id}`, + body: data, + isToken: true, + }); + return res; + } +} + +export const taskApi = new TaskApi(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tools.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tools.ts new file mode 100644 index 000000000..45da1602a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tools.ts @@ -0,0 +1,72 @@ +import * as fs from 'fs'; // 新增文件系统导入 +import netRequest from '.'; +import FormData from 'form-data'; + +export enum FeedbackType { + errReport = 'errReport', // 错误反馈 + feedback = 'feedback', // 反馈 + msgReport = 'msgReport', // 消息举报 + msgFeedback = 'msgFeedback', // 消息反馈 +} + +export class ToolsApi { + + // 获取AI的评论回复 + async aiRecoverReview(inData: { + content: string; + title?: string; + desc?: string; + max?: number; + }): Promise { + const res = await netRequest<{ + data: string; + code: number; + msg: string; + }>({ + method: 'POST', + url: 'tools/ai/recover/review', + body: inData, + }); + const { + status, + data: { data, code }, + } = res; + if (status !== 200 && status !== 201) return ''; + if (!!code) return ''; + return data; + } + + /** + * 上传本地文件 + * @param path + * @param secondPath + * @returns + */ + async upFile(path: string, secondPath = ''): Promise { + const formData = new FormData(); // 新增FormData实例 + const fileName = path.split('/').pop() || path.split('\\').pop() || 'file'; // 新增文件名提取 + formData.append('file', fs.createReadStream(path), fileName); // 新增文件流添加 + + const res = await netRequest<{ + data: string; + code: number; + msg: string; + }>({ + method: 'POST', + url: 'oss/upload', // 修改URL路径 + body: formData, // 使用FormData代替空对象 + headers: { + 'second-path': secondPath, + }, + }); + const { + status, + data: { data, code }, + } = res; + if (status !== 200 && status !== 201) return ''; + if (!!code) return ''; + return data; + } +} + +export const toolsApi = new ToolsApi(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tracing.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tracing.ts new file mode 100644 index 000000000..7b6ef5293 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/tracing.ts @@ -0,0 +1,140 @@ +import netRequest from '.'; + +export enum TracingType { + EVENT = 'event', // 事件 +} + +export enum TracingTag { + AccountAdd = 'AccountAdd', // 账号添加 + VideoPul = 'VideoPul', // 视频发布 + OpenProjectUse = 'OpenProjectUse', // 开源项目调用 +} + +export interface Tracing { + id: string; + userId: string; + type: TracingType; + tag: TracingTag; + accountId?: number; // 平台账号ID + desc?: string; + dataId?: string; // 关联数据id + createTime: string; + updateTime: string; +} + +export class TracingApi { + // 创建跟踪-账号添加 + async createTracingAccountAdd(account: { + id: number; + desc?: string; + }): Promise { + const inData: { + type: TracingType; + tag: string; + accountId?: number; // 平台账号ID + desc?: string; + dataId?: string; // 关联数据id + } = { + type: TracingType.EVENT, + tag: TracingTag.AccountAdd, + accountId: account.id, + desc: account.desc, + dataId: account.id + '', + }; + + const res = await netRequest<{ + data: Tracing; + code: number; + msg: string; + }>({ + method: 'POST', + url: 'tracing', + body: inData, + isToken: true, + }); + const { + status, + data: { data, code }, + } = res; + + if (status !== 200 && status !== 201) return null; + if (!!code) return null; + return data; + } + + // 创建跟踪-视频发布 + async createTracingVideoPul(inData: { + accountId: number; + dataId: string; // 视频发布数据ID + desc?: string; + }): Promise { + const body: { + type: TracingType; + tag: string; + accountId?: number; // 平台账号ID + desc?: string; + dataId?: string; // 关联数据id + } = { + type: TracingType.EVENT, + tag: TracingTag.VideoPul, + accountId: inData.accountId, + desc: inData.desc, + dataId: inData.dataId + '', + }; + + const res = await netRequest<{ + data: Tracing; + code: number; + msg: string; + }>({ + method: 'POST', + url: 'tracing', + body: body, + isToken: true, + }); + + const { + status, + data: { data, code }, + } = res; + if (status !== 200 && status !== 201) return null; + if (!!code) return null; + return data; + } + + // 创建跟踪-开源项目调用 + async createTracingOpenProjectUse(inData: { + desc?: string; + }): Promise { + const body: { + type: TracingType; + tag: string; + accountId?: number; // 平台账号ID + desc?: string; + dataId?: string; // 关联数据id + } = { + type: TracingType.EVENT, + tag: TracingTag.OpenProjectUse, + desc: inData.desc, + }; + + const res = await netRequest<{ + data: Tracing; + code: number; + msg: string; + }>({ + method: 'POST', + url: 'tracing', + body: body, + }); + const { + status, + data: { data, code }, + } = res; + if (status !== 200 && status !== 201) return null; + if (!!code) return null; + return data; + } +} + +export const tracingApi = new TracingApi(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/types/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/types/index.ts new file mode 100644 index 000000000..cec364359 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/api/types/index.ts @@ -0,0 +1,15 @@ +// 通用响应类型 +export interface ResOp { + data: T; + code?: number; + message?: string; +} + +// 分页元数据 +export interface PaginationMeta { + itemCount: number; + totalItems: number; + itemsPerPage: number; + totalPages: number; + currentPage: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/app.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/app.ts new file mode 100644 index 000000000..efdad9290 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/app.ts @@ -0,0 +1,50 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:33:22 + * @LastEditTime: 2025-03-18 20:53:15 + * @LastEditors: nevin + * @Description: + */ +import { Module } from './core/decorators'; +import { AccountModule } from './account/module'; +import { initSqlite3Db } from '../db'; +import { PublishModule } from './publish/module'; +import { UserModule } from './user/module'; +import { BackupModule } from './backup/module'; +import { TestModule } from './test/module'; +import { ToolsModule } from './tools/module'; +import { AppController } from './controller'; +import { AppService } from './service'; +import { ReplyModule } from './reply/module'; +import { AutoRunModule } from './autoRun/module'; +import { InteractionModule } from './interaction/module'; +import { TracingModule } from './tracing/module'; + +@Module({ + imports: [ + ToolsModule, + UserModule, + AccountModule, + PublishModule, + BackupModule, + TestModule, + ReplyModule, + AutoRunModule, + InteractionModule, + TracingModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class App { + constructor() { + this._init(); + } + + async _init() { + // 初始化数据库 + await initSqlite3Db(); + } +} + +export default App; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/comment.ts new file mode 100644 index 000000000..75c8cc74b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/comment.ts @@ -0,0 +1,88 @@ +/* + * @Author: nevin + * @Date: 2025-01-21 21:12:52 + * @LastEditTime: 2025-03-19 15:10:28 + * @LastEditors: nevin + * @Description: + */ +import { AutoRunType } from '../../db/models/autoRun'; + +export const autoRunTypeEtTag = new Map([ + [AutoRunType.ReplyComment, 'ET_AUTO_RUN_REPLY_COMMENT'], +]); + +export enum CycleType { + day = 'day', // 每天HH点触发 + week = 'week', // 每周D日触发(周日=0,周一=1,..., 周六=6) + month = 'month', // 每月DD日触发 +} + +// 工具函数:解析周期类型 +export function parseCycleType(cycleType: string): { + type: CycleType | ''; + param: number; +} { + const [_, type, paramStr] = cycleType.match(/(\w+)-(\d+)/) || []; + return { + type: type as CycleType, + param: parseInt(paramStr || '0'), + }; +} + +// 核心判断函数 +export function hasTriggered( + cycleType: string, + now: Date = new Date(), +): boolean { + const { type, param } = parseCycleType(cycleType); + const date = new Date(now); + + switch (type) { + case 'day': // 每天HH点触发 + const hour = param; + const todayTrigger = new Date(date); + todayTrigger.setHours(hour, 0, 0, 0); + if (todayTrigger <= date) { + // 当前时间已过当日触发时间 → 已触发 + return true; + } else { + // 未到当日触发时间 → 未触发 + return false; + } + + case 'week': // 每周D日触发(周日=0,周一=1,..., 周六=6) + const targetDay = param; + const daysAgo = (date.getDay() - targetDay + 7) % 7; + const lastTrigger = new Date(date); + lastTrigger.setDate(date.getDate() - daysAgo); + lastTrigger.setHours(0, 0, 0, 0); // 设置为当天0点 + if (lastTrigger <= date) { + // 当前时间已过最近触发日 → 已触发 + return true; + } else { + // 未到最近触发日 → 未触发 + return false; + } + + case 'month': // 每月DD日触发 + const targetDayOfMonth = param; + const currentMonth = date.getMonth(); + const currentYear = date.getFullYear(); + + const lastMonthTrigger = new Date( + currentYear, + currentMonth, + targetDayOfMonth, + ); + if (lastMonthTrigger <= date) { + // 当月触发日已过 → 已触发 + return true; + } else { + // 当月触发日未到 → 未触发 + return false; + } + + default: + return false; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/controller.ts new file mode 100644 index 000000000..d2bcf1d76 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/controller.ts @@ -0,0 +1,180 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-03-28 13:33:08 + * @LastEditors: nevin + * @Description: autoRun AutoRun + */ +import { Controller, Icp, Inject, Scheduled } from '../core/decorators'; +import { AutoRunService } from './service'; +import { AutoRunStatus, AutoRunType } from '../../db/models/autoRun'; +import { getUserInfo } from '../user/comment'; +import { EtEvent } from '../../global/event'; +import { autoRunTypeEtTag } from './comment'; +import { AutoRunRecordStatus } from '../../db/models/autoRunRecord'; + +@Controller() +export class AutoRunController { + @Inject(AutoRunService) + private readonly autoRunService!: AutoRunService; + + /** + * 创建进程 + */ + @Icp('ICP_AUTO_RUN_CREATE') + async createAutoRun( + event: Electron.IpcMainInvokeEvent, + info: { + accountId: number; + type: AutoRunType; + cycleType: string; + }, + data: Record, // 对象 + ) { + const userInfo = getUserInfo(); + + const autoRun = await this.autoRunService.createAutoRun( + { + userId: userInfo.id, + ...info, + }, + data, + ); + + return autoRun; + } + + /** + * 进程列表 + */ + @Icp('ICP_AUTO_RUN_LIST') + async getAutoRunList( + event: Electron.IpcMainInvokeEvent, + pageInfo: { + page: number; + pageSize: number; + }, + query: { + type?: AutoRunType; + status?: AutoRunStatus; + cycleType?: string; + accountId?: number; + dataId?: string; + }, + ) { + const list = await this.autoRunService.findAutoRunList(pageInfo, query); + return list; + } + + /** + * 更新进程状态 + */ + @Icp('ICP_AUTO_RUN_STATUS') + async updateAutoRunStatus( + event: Electron.IpcMainInvokeEvent, + id: number, + status: AutoRunStatus, + ) { + const autoRun = await this.autoRunService.updateAutoRunStatus(id, status); + + return autoRun; + } + + /** + * 创建进程记录 + */ + @Icp('ICP_AUTO_RUN_RECORD_CREATE') + async createAutoRunRecord( + event: Electron.IpcMainInvokeEvent, + autoRunId: number, + ) { + const autoData = await this.autoRunService.findAutoRunById(autoRunId); + if (!autoData) return null; + const autoRunRecord = + await this.autoRunService.createAutoRunRecord(autoData); + + return autoRunRecord; + } + + /** + * 更新进程记录状态 + */ + @Icp('ICP_AUTO_RUN_RECORD_STATUS') + async updateAutoRunRecordStatus( + event: Electron.IpcMainInvokeEvent, + id: number, + status: number, + ) { + const autoRun = await this.autoRunService.updateAutoRunRecordStatus( + id, + status, + ); + + return autoRun; + } + + /** + * 获取进程记录列表 + */ + @Icp('ICP_AUTO_RUN_RECORD_LIST') + async getCommentList( + event: Electron.IpcMainInvokeEvent, + pageInfo: { + page: number; + pageSize: number; + }, + query: { + autoRunId: number; + type?: AutoRunType; + status?: AutoRunRecordStatus; + cycleType?: string; + }, + ): Promise { + const list = await this.autoRunService.findAutoRunRecordList( + pageInfo, + query, + ); + + return list; + } + + // 立即执行进程 + @Icp('ICP_RUN_NOW_AUTO_RUN') + async autoRunStart(event: Electron.IpcMainInvokeEvent, id: number) { + const item = await this.autoRunService.findAutoRunById(id); + if (!item) return false; + + const tag = autoRunTypeEtTag.get(item.type); + if (!tag) return false; + + EtEvent.emit(tag, item); + + console.log('---- autoRunStart ----'); + return true; + } + + // 每5分钟进行一次自动启动 + @Scheduled('*/1 * * * *', 'all_auto_run_start') + async syncAllAutoRunStart() { + console.log('---- syncAllAutoRunStart ----'); + try { + const userInfo = getUserInfo(); + + const autoRunList = await this.autoRunService.findAutoRunListOfNeedRun( + userInfo.id, + ); + + autoRunList.map(async (item) => { + const tag = autoRunTypeEtTag.get(item.type); + if (!tag) return; + + const needRun = await this.autoRunService.isNeedAutoRunToRun(item); + if (!needRun) return; + + EtEvent.emit(tag, item); + }); + } catch (error) { + console.error('---- syncAllAutoRunStart ---- error', error); + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/module.ts new file mode 100644 index 000000000..e2d648f7e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-03-18 19:50:10 + * @LastEditors: nevin + * @Description: autoRun AutoRun 自动脚本 + */ +import { Module } from '../core/decorators'; +import { AutoRunController } from './controller'; +import { AutoRunService } from './service'; + +@Module({ + controllers: [AutoRunController], + providers: [AutoRunService], +}) +export class AutoRunModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/service.ts new file mode 100644 index 000000000..8fabf534d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/autoRun/service.ts @@ -0,0 +1,243 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: autoRun AutoRun + */ +import { Injectable } from '../core/decorators'; +import { + AutoRunModel, + AutoRunStatus, + AutoRunType, +} from '../../db/models/autoRun'; +import { + AutoRunRecordModel, + AutoRunRecordStatus, +} from '../../db/models/autoRunRecord'; +import { FindOptionsWhere, MoreThan, Not, Repository } from 'typeorm'; +import { AppDataSource } from '../../db'; +import windowOperate from '../../util/windowOperate'; +import { SendChannelEnum } from '../../../commont/UtilsEnum'; +import { hasTriggered, parseCycleType } from './comment'; + +@Injectable() +export class AutoRunService { + private autoRunRepository: Repository; + private autoRunRecordRepository: Repository; + + constructor() { + this.autoRunRepository = AppDataSource.getRepository(AutoRunModel); + this.autoRunRecordRepository = + AppDataSource.getRepository(AutoRunRecordModel); + } + + // 限制处于启动状态的任务数量 + private async chectAutoRunCount(userId: string): Promise { + const count = await this.autoRunRepository.count({ + where: { + userId, + status: AutoRunStatus.DOING, + }, + }); + + if (count >= 100) return false; + + return true; + } + + // 创建进程 + async createAutoRun(info: Partial, data: Record) { + if (!(await this.chectAutoRunCount(data.userId!))) return null; + + info.data = JSON.stringify(data); + return await this.autoRunRepository.save(info); + } + + // 根据ID查询进程信息 + async findAutoRunById(id: number) { + const info = await this.autoRunRepository.findOne({ + where: { + id, + }, + }); + + if (info) info.dataInfo = JSON.parse(info.data); + + return info; + } + + // 查询进程列表 + async findAutoRunList( + pageInfo: { + page: number; + pageSize: number; + }, + query: { + type?: AutoRunType; + status?: AutoRunStatus; + cycleType?: string; + accountId?: number; + dataId?: string; + }, + ): Promise<{ + list: AutoRunModel[]; + total: number; + }> { + const { page, pageSize } = pageInfo; + const whereClause: FindOptionsWhere = { + status: Not(AutoRunStatus.DELETE), + ...(query.type !== undefined && { type: query.type }), + ...(query.status !== undefined && { status: query.status }), + ...(query.cycleType !== undefined && { cycleType: query.cycleType }), + ...(query.accountId !== undefined && { accountId: query.accountId }), + ...(query.dataId !== undefined && { dataId: query.dataId }), + }; + + const list = await this.autoRunRepository.find({ + where: whereClause, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const total = await this.autoRunRepository.count({ + where: whereClause, + }); + + return { + list, + total, + }; + } + + // 查询需要运行的进程列表 + async findAutoRunListOfNeedRun(userId: string) { + return await this.autoRunRepository.find({ + where: { + userId, + status: AutoRunStatus.DOING, + }, + }); + } + + // 更新进程状态 + async updateAutoRunStatus(id: number, status: AutoRunStatus) { + return await this.autoRunRepository.update(id, { + status, + }); + } + + // 创建进程记录 + async createAutoRunRecord(autoRun: AutoRunModel) { + const data = { + autoRunId: autoRun.id, + userId: autoRun.userId, + type: autoRun.type, + cycleType: autoRun.cycleType, + status: AutoRunRecordStatus.DOING, + }; + return await this.autoRunRecordRepository.save(data); + } + + // 查询进程记录列表 + async findAutoRunRecordList( + pageInfo: { + page: number; + pageSize: number; + }, + query: { + autoRunId: number; + type?: AutoRunType; + status?: AutoRunRecordStatus; + cycleType?: string; + }, + ) { + const { page, pageSize } = pageInfo; + const whereClause: FindOptionsWhere = { + ...(query.autoRunId !== undefined && { autoRunId: query.autoRunId }), + ...(query.type !== undefined && { type: query.type }), + ...(query.status !== undefined && { status: query.status }), + ...(query.cycleType !== undefined && { cycleType: query.cycleType }), + }; + + const list = await this.autoRunRecordRepository.find({ + where: whereClause, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const total = await this.autoRunRecordRepository.count({ + where: whereClause, + }); + + return { + list, + total, + }; + } + + // 更新进程记录状态 + async updateAutoRunRecordStatus(id: number, status: AutoRunRecordStatus) { + return await this.autoRunRecordRepository.update(id, { + status, + }); + } + + // 发送自动任务进度通知 + async sendAutoRunProgress(id: number, status: -1 | 0 | 1 | 2, error?: any) { + const autoRunInfo = await this.findAutoRunById(id); + if (!autoRunInfo) return; + + windowOperate.sendRenderMsg( + SendChannelEnum.AutoRun, + status, + autoRunInfo, + error, + ); + } + + // 查找周期内的最近一条记录 + async findLastAutoRunRecord( + autoRun: AutoRunModel, + cycleType: 'day' | 'week' | 'month', + ) { + const where: FindOptionsWhere = { + autoRunId: autoRun.id, + }; + + if (cycleType === 'day') { + where.createTime = MoreThan(new Date(new Date().setHours(0, 0, 0, 0))); + } else if (cycleType === 'week') { + where.createTime = MoreThan( + new Date(new Date().setDate(new Date().getDate() - 7)), + ); + } else if (cycleType === 'month') { + where.createTime = MoreThan( + new Date(new Date().setDate(new Date().getDate() - 30)), + ); + } + + const lastRecord = await this.autoRunRecordRepository.findOne({ + where, + order: { + createTime: 'DESC', + }, + }); + + return lastRecord; + } + + // 判断是否需要触发运行 + async isNeedAutoRunToRun(autoRun: AutoRunModel): Promise { + const { cycleType } = autoRun; + + const isHasTriggered = hasTriggered(cycleType); + if (!isHasTriggered) return false; + + const { type } = parseCycleType(cycleType); + if (!type) return false; + + const record = await this.findLastAutoRunRecord(autoRun, type); + + return !record; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/controller.ts new file mode 100644 index 000000000..6ae86a15c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/controller.ts @@ -0,0 +1,33 @@ +import { Controller, Icp, Inject } from '../core/decorators'; +import { BackupService } from './service'; + +@Controller() +export class BackupController { + @Inject(BackupService) + private readonly backupService!: BackupService; + + /** + * 创建备份 + * @param name 备份名称(可选) + * @returns 备份文件路径 + */ + @Icp('backup:create') + async createBackup( + event: Electron.IpcMainInvokeEvent, + name?: string, + ): Promise { + return await this.backupService.createBackup(name); + } + + /** + * 从备份文件恢复 + * @param backupPath 备份文件路径 + */ + @Icp('backup:restore') + async restoreFromBackup( + event: Electron.IpcMainInvokeEvent, + backupPath: string, + ): Promise { + await this.backupService.restoreFromBackup(backupPath); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/module.ts new file mode 100644 index 000000000..e42fbd759 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/module.ts @@ -0,0 +1,9 @@ +import { Module } from '../core/decorators'; +import { BackupController } from './controller'; +import { BackupService } from './service'; + +@Module({ + controllers: [BackupController], + providers: [BackupService], +}) +export class BackupModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/service.ts new file mode 100644 index 000000000..e3108e584 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/backup/service.ts @@ -0,0 +1,59 @@ +import { app } from 'electron'; +import path from 'path'; +import fs from 'fs/promises'; +import { exportDatabase, importDatabase } from '../../db'; +import { Injectable } from '../core/decorators'; + +@Injectable() +export class BackupService { + private backupDir: string; + + constructor() { + // 在用户数据目录下创建备份文件夹 + this.backupDir = path.join(app.getPath('userData'), 'backups'); + this.initBackupDir(); + } + + /** + * 初始化备份目录 + */ + private async initBackupDir() { + try { + await fs.mkdir(this.backupDir, { recursive: true }); + } catch (error) { + console.error('Failed to create backup directory:', error); + } + } + + /** + * 创建备份 + * @param name 备份名称(可选) + * @returns 备份文件路径 + */ + async createBackup(name?: string): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `${name ? name + '_' : ''}${timestamp}.sql`; + const backupPath = path.join(this.backupDir, fileName); + + try { + await exportDatabase(backupPath); + return backupPath; + } catch (error) { + console.error('Failed to create backup:', error); + throw error; + } + } + + /** + * 从备份文件恢复 + * @param backupPath 备份文件路径 + */ + async restoreFromBackup(backupPath: string): Promise { + try { + await importDatabase(backupPath); + } catch (error) { + console.error('Failed to restore from backup:', error); + throw error; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/comment.ts new file mode 100644 index 000000000..fcaebf09a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/comment.ts @@ -0,0 +1,7 @@ +/* + * @Author: nevin + * @Date: 2025-01-21 21:12:52 + * @LastEditTime: 2025-01-21 21:13:58 + * @LastEditors: nevin + * @Description: 常量 通用 + */ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/controller.ts new file mode 100644 index 000000000..bca2738f5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/controller.ts @@ -0,0 +1,23 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-03-31 11:30:32 + * @LastEditors: nevin + * @Description: + */ +import { Controller, Icp, Inject } from './core/decorators'; +import { AppService } from './service'; + +@Controller() +export class AppController { + @Inject(AppService) + private readonly toolsService!: AppService; + + /** + * 获取应用信息 + */ + @Icp('ICP_APP_GET_INFO') + async getAppInfo(event: Electron.IpcMainInvokeEvent) { + return this.toolsService.getAppInfo(); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/container.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/container.ts new file mode 100644 index 000000000..112851687 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/container.ts @@ -0,0 +1,133 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:55:15 + * @LastEditTime: 2025-01-24 19:50:24 + * @LastEditors: nevin + * @Description: 容器 + */ +import { INJECT_METADATA_KEY } from './metadata'; +// import { AppDataSource } from '../../db'; + +// 创建一个容器类来管理依赖注入 +export class Container { + private static instance: Container; + private readonly providers = new Map(); + private readonly providerClasses = new Map(); + private readonly controllers = new Map(); + // 用于检测循环依赖 + private readonly dependencyStack: string[] = []; + + private constructor() {} + + static getInstance(): Container { + if (!Container.instance) { + Container.instance = new Container(); + } + return Container.instance; + } + + // 注册 provider 类 + registerProvider(providerClass: any) { + const providerName = providerClass.name; + if (!this.providerClasses.has(providerName)) { + // 检查依赖关系 + this.checkCircularDependencies(providerClass); + this.providerClasses.set(providerName, providerClass); + } + } + + // 检查循环依赖 + private checkCircularDependencies( + targetClass: any, + visited = new Set(), + ) { + const className = targetClass.name; + + if (visited.has(className)) { + const dependencyPath = [...visited, className].join(' -> '); + throw new Error(`Circular dependency detected: ${dependencyPath}`); + } + + visited.add(className); + + const injections = + Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || []; + for (const { serviceType } of injections) { + const dependencyClass = serviceType; + this.checkCircularDependencies(dependencyClass, new Set(visited)); + } + } + + // 获取或创建 provider 实例 + getProvider(name: string) { + if (this.dependencyStack.includes(name)) { + throw new Error( + `Circular dependency detected: ${[...this.dependencyStack, name].join( + ' -> ', + )}`, + ); + } + + if (!this.providers.has(name)) { + const providerClass = this.providerClasses.get(name); + if (!providerClass) { + throw new Error(`Provider ${name} not registered`); + } + + this.dependencyStack.push(name); + const instance = new providerClass(); + this.injectDependencies(instance, providerClass); + this.dependencyStack.pop(); + + this.providers.set(name, instance); + } + return this.providers.get(name); + } + + // 注入依赖 + private injectDependencies(instance: any, targetClass: any) { + const injections = + Reflect.getMetadata(INJECT_METADATA_KEY, targetClass) || []; + injections.forEach(({ propertyKey, serviceType }: any) => { + instance[propertyKey] = this.getProvider(serviceType.name); + }); + } + + // Controller 相关方法 + setController(name: string, controller: any) { + if (!this.controllers.has(name)) { + this.injectDependencies(controller, controller.constructor); + this.controllers.set(name, controller); + } + } + + getController(name: string) { + return this.controllers.get(name); + } + + hasController(name: string) { + return this.controllers.has(name); + } + + getAllProviders() { + return this.providers; + } + + getAllControllers() { + return this.controllers; + } + + // 添加初始化方法 + async initialize() { + // 初始化所有已注册的 providers + for (const [name, providerClass] of this.providerClasses.entries()) { + if (!this.providers.has(name)) { + const instance = new providerClass(); + this.injectDependencies(instance, providerClass); + this.providers.set(name, instance); + } + } + } +} + +export const container = Container.getInstance(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/decorators.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/decorators.ts new file mode 100644 index 000000000..52b0bdb9e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/decorators.ts @@ -0,0 +1,128 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:55:22 + * @LastEditTime: 2025-03-19 14:31:34 + * @LastEditors: nevin + * @Description: + */ +import 'reflect-metadata'; +import { ipcMain } from 'electron'; +import { container } from './container'; +import { INJECT_METADATA_KEY } from './metadata'; +import { EtEvent } from '../../global/event'; +import { scheduleJob, scheduleJobMap } from '../../global/schedule'; + +// Module 装饰器 +export function Module(metadata: { + imports?: any[]; + controllers?: any[]; + providers?: any[]; +}) { + return function (target: any) { + // 只注册 providers 和 controllers,不立即初始化 + metadata.providers?.forEach((provider) => { + container.registerProvider(provider); + }); + + metadata.controllers?.forEach((controller) => { + const controllerName = controller.name; + if (!container.hasController(controllerName)) { + const instance = new controller(); + container.setController(controllerName, instance); + } + }); + }; +} + +// Inject 装饰器 +export function Inject(serviceType: any) { + return function (target: any, propertyKey: string) { + const injections = + Reflect.getMetadata(INJECT_METADATA_KEY, target.constructor) || []; + injections.push({ propertyKey, serviceType }); + Reflect.defineMetadata(INJECT_METADATA_KEY, injections, target.constructor); + }; +} + +// Controller 装饰器 +export function Controller() { + return function (target: any) { + Reflect.defineMetadata('isController', true, target); + }; +} + +// Injectable 装饰器 +export function Injectable() { + return function (target: any) { + Reflect.defineMetadata('isInjectable', true, target); + }; +} + +// IPC 方法装饰器 +export function Icp(channel: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + ipcMain.handle(channel, async (event, ...args) => { + const controller = container.getController(target.constructor.name); + if (!controller) { + throw new Error(`Controller ${target.constructor.name} not found`); + } + return await originalMethod.bind(controller)(event, ...args); + }); + + return descriptor; + }; +} + +// Event事件监听装饰器 +export function Et(eventName: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + EtEvent.on(eventName, async (...args) => { + const controller = container.getController(target.constructor.name); + if (!controller) { + throw new Error(`Controller ${target.constructor.name} not found`); + } + return await originalMethod.bind(controller)(...args); + }); + }; +} + +/** + * 定时任务装饰器 + * @param cron + * @param key + * @returns + */ +export function Scheduled(cron: string, key?: string) { + return function ( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, + ) { + const originalMethod = descriptor.value; + + // 使用 node-schedule 创建定时任务 + const job = scheduleJob.scheduleJob(cron, async () => { + const controller = container.getController(target.constructor.name); + if (!controller) { + throw new Error(`Controller ${target.constructor.name} not found`); + } + await originalMethod.bind(controller)(); + }); + + if (!!key) scheduleJobMap.set(key, job); + + return descriptor; + }; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/metadata.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/metadata.ts new file mode 100644 index 000000000..2c41711fd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/core/metadata.ts @@ -0,0 +1,8 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 18:16:44 + * @LastEditTime: 2025-01-24 18:18:06 + * @LastEditors: nevin + * @Description: 元数据键 + */ +export const INJECT_METADATA_KEY = Symbol('INJECT_METADATA_KEY'); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/index.ts new file mode 100644 index 000000000..575d5f5ea --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/index.ts @@ -0,0 +1,208 @@ +import { app, BrowserWindow, shell, ipcMain, nativeTheme } from 'electron'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import os from 'node:os'; +import { update } from './update'; +import { SystemTray } from '../tray/systemTray'; +import { views } from './views'; +import App from './app'; +import { getAssetPath } from '../util/index'; +import windowOperate from '../util/windowOperate'; +import { logger } from '../global/log'; +import { SplashWindow } from './splash'; +import dotenv from 'dotenv'; +import KwaiPubListener from './plat/platforms/Kwai/KwaiPubListener'; +import { registerContextMenuListener } from '@electron-uikit/contextmenu'; +import { dialog } from 'electron'; + +const platform = process.platform; +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +process.env.APP_ROOT = path.join(__dirname, '../..'); + +export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron'); +export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); +export const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL; + +dialog.showErrorBox = (title, content) => { + console.error(`Error: ${title}\n${content}`); +}; + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, 'public') + : RENDERER_DIST; + +// Disable GPU Acceleration for Windows 7 +if (os.release().startsWith('6.1')) app.disableHardwareAcceleration(); +// Set application name for Windows 10+ notifications +if (process.platform === 'win32') app.setAppUserModelId(app.getName()); + +// 单例锁 +// if (!app.requestSingleInstanceLock()) { +// app.quit(); +// process.exit(0); +// } + +let win: BrowserWindow | null = null; +let splashWindow: SplashWindow | null = null; +const preload = path.join(__dirname, '../preload/index.mjs'); +const indexHtml = path.join(RENDERER_DIST, 'index.html'); + +async function createWindow() { + // 创建启动窗口 + splashWindow = new SplashWindow(); + splashWindow.create(); + + // 等待一会儿确保启动窗口显示 + await new Promise((resolve) => setTimeout(resolve, 500)); + + // 创建主窗口但先不显示 + win = new BrowserWindow({ + title: '哎哟赚AiToEarn', + icon: path.join(getAssetPath('favicon.ico')), + width: 2350, + height: 1280, + minWidth: 1280, + minHeight: 800, + titleBarStyle: 'hidden', + show: false, + titleBarOverlay: + platform === 'win32' + ? undefined + : { + color: 'rgba(0,0,0,0)', + height: 64, + symbolColor: '#595959', + }, + webPreferences: { + preload, + webviewTag: true, + webSecurity: true, + nodeIntegration: false, + contextIsolation: true, + }, + }); + + // 强制使用非黑暗模式 + nativeTheme.themeSource = 'light'; + + try { + const tray = new SystemTray(win); + tray.create(); + } catch (error) { + logger.error('系统托盘启动失败', error); + } + + // 等待主窗口加载完成 + if (VITE_DEV_SERVER_URL) { + await win.loadURL(VITE_DEV_SERVER_URL); + } else { + await win.loadFile(indexHtml); + } + + // 延长启动窗口显示时间 + KwaiPubListener.start(); + setTimeout(() => { + if (splashWindow) { + win?.show(); + // 在主窗口显示后再打开开发者工具 + // win?.webContents.openDevTools({ mode: 'right' }); + + if (process.env.NODE_ENV === 'development') { + win?.webContents.openDevTools({ mode: 'right' }); + } + + // if (VITE_DEV_SERVER_URL) { + // win?.webContents.openDevTools({ mode: 'bottom' }); + // } + setTimeout(() => { + if (splashWindow) { + splashWindow.close(); + splashWindow = null; + } + }, 100); + } + }, 500); + + // 隐藏菜单栏 + win.setMenu(null); + + // Test actively push message to the Electron-Renderer + win.webContents.on('did-finish-load', () => { + win?.webContents.send('main-process-message', new Date().toLocaleString()); + }); + + // Make all links open with the browser, not with the application + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith('https:')) shell.openExternal(url); + return { action: 'deny' }; + }); + + return win; +} + +app.whenReady().then(async () => { + try { + registerContextMenuListener(); + + // 创建应用实例,挂载功能 + new App(); + + // 创建窗口 + const bWin = await createWindow(); + + // 挂载其他功能 + update(bWin); + views(bWin); + windowOperate.init(bWin); + } catch (error) { + logger.error('Failed to start application:', error); + app.quit(); + } +}); + +/** + * Quit when all windows are closed, except on macOS. There, it's common + */ +app.on('window-all-closed', () => { + win = null; + if (process.platform !== 'darwin') app.quit(); +}); + +// 处理第二个实例 +app.on('second-instance', () => { + if (win) { + // Focus on the main window if the user tried to open another + if (win.isMinimized()) win.restore(); + win.focus(); + } +}); + +// 处理激活 +app.on('activate', () => { + const allWindows = BrowserWindow.getAllWindows(); + if (allWindows.length) { + allWindows[0].focus(); + } else { + createWindow(); + } +}); + +// 打开新窗口 +ipcMain.handle('open-win', (_, arg) => { + const childWindow = new BrowserWindow({ + webPreferences: { + preload, + nodeIntegration: true, + contextIsolation: false, + }, + }); + + if (VITE_DEV_SERVER_URL) { + childWindow.loadURL(`${VITE_DEV_SERVER_URL}#${arg}`); + } else { + childWindow.loadFile(indexHtml, { hash: arg }); + } +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/cacheData.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/cacheData.ts new file mode 100644 index 000000000..e710432dc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/cacheData.ts @@ -0,0 +1,67 @@ +import { GlobleCache } from '../../global/cache'; + +// 0 进行中 1 完成 2 错误 +export enum AutoInteractionCacheStatus { + DOING = 0, + DONE = 1, + REEOR = 2, +} + +export type AutoReplyCacheData = { + status: AutoInteractionCacheStatus; + message: string; + createTime?: number; + updateTime?: number; + title: string; + dataId?: string; +}; + +export class AutoInteractionCache { + static cacheKey = 'OneKeyInteractionWorksCacheKey'; + constructor(data: { title: string }) { + const cacheData = { + status: AutoInteractionCacheStatus.DOING, + message: '进行中', + updateTime: new Date().getTime(), + createTime: + ( + GlobleCache.getCache( + AutoInteractionCache.cacheKey, + ) as AutoReplyCacheData + )?.createTime || new Date().getTime(), + ...data, + }; + + GlobleCache.setCache(AutoInteractionCache.cacheKey, cacheData, 60 * 30); // 设置缓存 + } + + // 获取信息 + static getInfo() { + return GlobleCache.getCache( + AutoInteractionCache.cacheKey, + ) as AutoReplyCacheData | null; + } + + // 延长ttl + extendTTL() { + GlobleCache.updateCacheTTL(AutoInteractionCache.cacheKey, 60 * 30); // 重设缓存时间 + } + + // 更新状态 + updateStatus(status: AutoInteractionCacheStatus, message?: string) { + const cacheData = GlobleCache.getCache(AutoInteractionCache.cacheKey); + if (cacheData) { + GlobleCache.setCache(AutoInteractionCache.cacheKey, { + ...cacheData, + status, + message, + updateTime: new Date().getTime(), + }); + } + } + + // 删除 + delete() { + GlobleCache.delCache(AutoInteractionCache.cacheKey); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/controller.ts new file mode 100644 index 000000000..ca849d007 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/controller.ts @@ -0,0 +1,248 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-04-27 09:51:25 + * @LastEditors: nevin + * @Description: interaction Interaction 互动 + */ +import windowOperate from '../../util/windowOperate'; +import { AutoRunModel, AutoRunType } from '../../db/models/autoRun'; +import { AccountService } from '../account/service'; +import { AutoRunService } from '../autoRun/service'; +import { Controller, Et, Icp, Inject, Scheduled } from '../core/decorators'; +import { InteractionService } from './service'; +import { SendChannelEnum } from '../../../commont/UtilsEnum'; +import type { WorkData } from '../plat/plat.type'; +import { AutorWorksInteractionScheduleEvent } from '../../../commont/types/interaction'; +import { AutoInteractionCache } from './cacheData'; +import { getUserInfo } from '../user/comment'; +import type { CorrectQuery } from '../../global/table'; +import { PlatType } from '../../../commont/AccountEnum'; +import { taskApi } from '../api/taskApi'; +import platController from '../plat'; + +@Controller() +export class InteractionController { + @Inject(InteractionService) + private readonly interactionService!: InteractionService; + + @Inject(AccountService) + private readonly accountService!: AccountService; + + @Inject(AutoRunService) + private readonly autoRunService!: AutoRunService; + + /** + * 一键AI互动 + */ + @Icp('ICP_INTERACTION_ONE_DATA') + async interactionOneData( + event: Electron.IpcMainInvokeEvent, + accountId: number, + works: WorkData, + option: { + commentContent: string; // 评论内容 + }, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await this.interactionService.autorInteraction( + account, + [works], + option as any, + (e: { + tag: AutorWorksInteractionScheduleEvent; + status: -1 | 0 | 1; + data?: any; + error?: any; + }) => { + windowOperate.sendRenderMsg(SendChannelEnum.CommentRelyProgress, e); + }, + ); + + return res; + } + + /** + * 一键AI互动 + */ + @Icp('ICP_INTERACTION_ONE_KEY') + async interactionOneKey( + event: Electron.IpcMainInvokeEvent, + accountId: number, + worksList: WorkData[], + option: { + commentContent: string; // 评论内容 + taskId?: string; // 任务ID + platform?: string; // 平台ID + likeProb?: any; // 点赞概率 + collectProb?: any; // 收藏概率 + commentProb?: any; // 评论概率 + commentType: any; // 评论类型 + }, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await this.interactionService.autorInteraction( + account, + worksList, + option, + (e: { + tag: AutorWorksInteractionScheduleEvent; + status: -1 | 0 | 1; + data?: any; + error?: any; + }) => { + windowOperate.sendRenderMsg(SendChannelEnum.InteractionProgress, e); + }, + ); + + return res; + } + + /** + * 创建自动AI评论截流任务 + */ + @Icp('ICP_AUTO_RUN_INTERACTION') + async createReplyCommentAutoRun( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + cycleType: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await this.autoRunService.createAutoRun( + { + accountId, + cycleType, + type: AutoRunType.ReplyComment, + userId: account.uid, + }, + data, + ); + + return res; + } + + // 运行自动评论任务 + @Et('ET_AUTO_RUN_REPLY_COMMENT') + async runAutoReplyComment(autoRunData: AutoRunModel): Promise { + const { accountId, dataInfo } = autoRunData; + if (!dataInfo) return false; + + const account = await this.accountService.getAccountById(accountId); + if (!account) return false; + + const res = await this.interactionService.addReplyQueue( + account, + dataInfo as WorkData[], + { + commentContent: dataInfo.commentContent, + }, + autoRunData, + ); + + return res.status === 1; + } + + /** + * 获取AI评论截流的记录列表 + */ + @Icp('ICP_GET_INTERACTION_RECORD_LIST') + async getInteractionRecordList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + query: { + accountId?: number; + type?: PlatType; + }, + ): Promise { + const userInfo = getUserInfo(); + + return this.interactionService.getInteractionRecordList( + userInfo.id, + page, + query, + ); + } + + /** + * 获取AI评论截流的任务信息 + */ + @Icp('ICP_GET_AUTO_INTERACTION_INFO') + async getAutoInteractionInfo( + event: Electron.IpcMainInvokeEvent, + ): Promise { + return AutoInteractionCache.getInfo(); + } + + // 自动互动, 每10秒进行 + @Scheduled('0 * * * * *', 'autoHudong') + async zidongHudong() { + // return; + console.log('自动互动 ing ...'); + const res = await taskApi.getActivityTask(); + console.log('---- getActivityTask ----', res); + const accountList = await this.accountService.getAccounts(); + // console.log('---- accountList ----', accountList); + if (res.items.length > 0) { + for (const item of res.items) { + for (const accountType of item.accountTypes) { + let myAccountTypeList = []; + for (const account of accountList) { + if (account.type === accountType && account.status === 0) { + myAccountTypeList.push(account); + // 申请任务 + try { + const applyTaskRes = await taskApi.applyTask(item.id || item._id, { + account: account.account, + uid: account.uid, + accountType: account.type, + }); + console.log('---- applyTaskRes ----', applyTaskRes); + if (applyTaskRes.data.data?.data) { + item.taskId = applyTaskRes.data.data?.data.id; + } + } catch (error) { + console.error('申请任务失败:', error); + } + } + } + // console.log('---- myAccountTypeList ----', myAccountTypeList); + + for (const account of myAccountTypeList) { + // console.log('---- account ----', account); + console.log('---- item.dataInfo?.commentContent ----', item.dataInfo?.commentContent); + const autorInteractionList = this.interactionService.getAutorInteractionList(account, [{ + author: {id: item.dataInfo?.authorId || ''} , + data : {id: item.dataInfo.worksId, xsec_token: item.dataInfo?.xsec_token || ''}, + dataId : item.dataInfo.worksId, + option : {xsec_token: item.dataInfo?.xsec_token || ''}, + title : item.title, + }], { + accountType: accountType, + commentContent: item.dataInfo?.commentContent, + }); + + // 提交完成任务 + console.log('---- autorInteractionList ----', autorInteractionList); + let submitTasStr = '作品'+ item.dataInfo.worksId + '账户'+ account.nickname + '账户ID'+ account.uid; + console.log('item.taskId', item.taskId) + if (item.taskId) { + const submitTaskRes = await taskApi.submitTask(item.taskId, { + submissionUrl: submitTasStr, + screenshotUrls: [], + qrCodeScanResult: submitTasStr, + }); + } + // console.log('---- submitTaskRes ----', submitTaskRes); + } + } + } + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/module.ts new file mode 100644 index 000000000..c923ca8b6 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/module.ts @@ -0,0 +1,19 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-03-20 22:26:30 + * @LastEditors: nevin + * @Description: reply Reply 评论 + */ +import { AccountModule } from '../account/module'; +import { AutoRunModule } from '../autoRun/module'; +import { Module } from '../core/decorators'; +import { InteractionController } from './controller'; +import { InteractionService } from './service'; + +@Module({ + imports: [AccountModule, AutoRunModule], + controllers: [InteractionController], + providers: [InteractionService], +}) +export class InteractionModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/service.ts new file mode 100644 index 000000000..569e766ba --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/interaction/service.ts @@ -0,0 +1,516 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: interactionRecord InteractionRecord + */ +import { Inject, Injectable } from '../core/decorators'; +import PQueue from 'p-queue'; +import { AccountModel } from '../../db/models/account'; +import platController from '../plat'; +import { toolsApi } from '../api/tools'; +import { AutoRunService } from '../autoRun/service'; +import { AutoRunModel } from '../../db/models/autoRun'; +import { sysNotice } from '../../global/notice'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { AppDataSource } from '../../db'; +import { getUserInfo } from '../user/comment'; +import { AutoRunRecordStatus } from '../../db/models/autoRunRecord'; +import { sleep } from '../../util/time'; +import { InteractionRecordModel } from '../../db/models/interactionRecord'; +import { AutorWorksInteractionScheduleEvent } from '../../../commont/types/interaction'; +import { WorkData } from '../plat/plat.type'; +import { AutoInteractionCache } from './cacheData'; +import { backPageData, CorrectQuery } from '../../global/table'; +import { PlatType } from '../../../commont/AccountEnum'; +import { AccountService } from '../account/service'; +import { UserService } from '../user/service'; +// import { ReplyController } from '../reply/controller'; + + +@Injectable() +export class InteractionService { + interactionQueue: PQueue; + private interactionRecordRepository: Repository; + + constructor() { + this.interactionQueue = new PQueue({ concurrency: 1 }); + this.interactionRecordRepository = AppDataSource.getRepository( + InteractionRecordModel, + ); + } + + @Inject(AutoRunService) + private readonly autoRunService!: AutoRunService; + + // 创建互动记录 + async createInteractionRecord( + userId: string, + account: AccountModel, + works: { + worksId: string; + worksTitle?: string; + worksCover?: string; + }, + commentRemark: string, + commentContent: string, + isLike: 0 | 1, + isCollect: 0 | 1, + ) { + return await this.interactionRecordRepository.save({ + userId, + accountId: account.id, + type: account.type, + worksId: works.worksId, + worksTitle: works.worksTitle, + worksCover: works.worksCover, + commentRemark, + commentContent, + isLike: isLike, + isCollect: isCollect, + }); + } + + // 获互动记录 + async getInteractionRecord( + userId: string, + account: AccountModel, + worksId: string, + ) { + return await this.interactionRecordRepository.findOne({ + where: { + userId, + accountId: account.id, + type: account.type, + worksId: worksId + '', + }, + }); + } + + // 获取互动记录列表 + async getInteractionRecordList( + userId: string, + page: CorrectQuery, + query: { + accountId?: number; + type?: PlatType; + }, + ) { + const filter: FindOptionsWhere = { + userId, + ...(query.accountId && { accountId: query.accountId }), + ...(query.type && { type: query.type }), + }; + + const [list, totalCount] = + await this.interactionRecordRepository.findAndCount({ + where: filter, + order: { + createTime: 'DESC', + }, + }); + + return backPageData(list, totalCount, page); + } + + /** + * 自动AI评论截流:作品评论,收藏,点赞 + * 规则:评论作品,已经评论不评论 + */ + async autorInteraction( + account: AccountModel, + worksList: WorkData[], + option: { + commentContent?: string; // 评论内容 + taskId?: string; // 任务ID + platform?: string; // 平台ID + likeProb?: any; // 点赞概率 + collectProb?: any; // 收藏概率 + commentProb?: any; // 评论概率 + commentType: any; // 评论类型 + }, + scheduleEvent: (data: { + tag: AutorWorksInteractionScheduleEvent; + status: -1 | 0 | 1; // -1 错误 0 进行中 1 完成 + data?: any; // 数据 + error?: any; + }) => void, + ) { + const commentContentList = option.commentContent + ? option.commentContent.split(',') + : []; + console.log('------ commentContentList ----', commentContentList); + // return; + + const userInfo = getUserInfo(); + + // 设置缓存 + const cacheData = new AutoInteractionCache({ + title: '互动任务', + }); + + try { + console.log( + '------ 开始执行互动任务,作品数量:', + worksList.length, + worksList[0], + ); + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.Start, + status: 0, + }); + + // 1. 循环AI回复评论 + let i = 0; + for (const works of worksList) { + // console.log('------ 开始处理作品:', works); + // 等待 + if (i > 0) await sleep(10 * 1000); + i++; + const oldRecord = await this.getInteractionRecord( + userInfo.id, + account, + works.dataId, + ); + if (oldRecord) continue; + + // console.log('option.commentContent', option); + let thisCommentContent = ''; + if (option.commentType && option.commentType == 'ai') { + const aiRes = await toolsApi.aiRecoverReview({ + content: (works.desc || '') + (works.title || ''), + }); + + // AI接口错误 + if (!aiRes) { + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.Error, + status: -1, + error: '未获得AI产出内容', + }); + cacheData.delete(); + return false; + } + + thisCommentContent = aiRes; + } + + if (option.commentType && option.commentType == 'copy') { + const commentList = await platController.getCommentList( + account, + { + dataId: works.dataId, + option: { + xsec_token: works.data?.xsec_token || '', + }, + }, + '0', + ); + + // console.log('------ commentList', commentList); + + const randomIndex = Math.floor( + Math.random() * commentList.list.length, + ); + thisCommentContent = commentList.list[randomIndex].content; + + // option.commentContent = aiRes; + } + + console.log('------ option.commentType', option.commentType); + if (option.commentType && option.commentType == 'custom') { + // let commentContentList = option.commentContent.split(','); + console.log('------ commentContentList', commentContentList); + const randomIndex = Math.floor( + Math.random() * commentContentList.length, + ); + console.log('------ randomIndex', randomIndex); + thisCommentContent = commentContentList[randomIndex]; + } + console.log('------ option.commentContent', thisCommentContent); + // return; + + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.ReplyCommentStart, + data: { + aiContent: thisCommentContent, + }, + status: 0, + }); + + // ----- 1-评论作品 ----- + console.log( + '------ 开始评论作品:', + works.dataId, + thisCommentContent, + works.author?.id, + ); + + // 判断是否执行评论 + const shouldComment = + option.commentProb === 0 + ? false + : !option.commentProb || Math.random() * 100 < option.commentProb; + let commentWorksRes: any = {}; + + if (shouldComment) { + // if (option.commentContent.includes(',')) { + // const randomIndex = Math.floor( + // Math.random() * option.commentContent.split(',').length, + // ); + // option.commentContent = + // option.commentContent.split(',')[randomIndex]; + // } + + console.log('------ option.commentContent', thisCommentContent); + + commentWorksRes = await platController.createCommentByOther( + account, + works.dataId, + thisCommentContent, + works.author?.id, + ); + console.log('------ 评论作品结果:', commentWorksRes); + + // 错误处理 + if (!commentWorksRes) { + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.ReplyCommentEnd, + status: -1, + error: '回复评论失败', + }); + continue; + } + + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.ReplyCommentEnd, + status: 0, + }); + } + + // ----- 2-点赞作品 ----- + let isLike: 0 | 1 = 0; + // 判断是否执行点赞 + const randomLike = Math.random() * 100; + console.log( + '判断是否执行点赞', + '概率:', + option.likeProb, + '随机值:', + randomLike, + ); + const shouldLike = + option.likeProb === 0 + ? false + : !option.likeProb || randomLike < option.likeProb; + + console.log('------ shouldLike', shouldLike); + + if (shouldLike) { + try { + console.log('------ 开始点赞作品:', works.dataId, works.author?.id); + const isLikeRes = await platController.dianzanDyOther( + account, + works.dataId, + { + authid: works.author?.id, + }, + ); + isLike = isLikeRes ? 1 : 0; + console.log('------ 点赞结果:', isLikeRes); + } catch (error) { + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.Error, + status: 0, + error, + data: { + isLike, + }, + }); + } + } + + // ----- 3-收藏作品 ----- + let isCollect: 0 | 1 = 0; + + // 判断是否执行收藏 + const randomCollect = Math.random() * 100; + console.log( + '判断是否执行收藏', + '概率:', + option.collectProb, + '随机值:', + randomCollect, + ); + const shouldCollect = + (option.collectProb === 0 + ? false + : !option.collectProb || randomCollect < option.collectProb) && + option.platform != 'KWAI'; + + if (shouldCollect) { + try { + const isCollectRes = await platController.shoucangDyOther( + account, + works.dataId, + ); + isCollect = isCollectRes ? 1 : 0; + } catch (error) { + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.Error, + status: 0, + error, + data: { + isLike, + }, + }); + } + } + + let commentRemark = ''; + if (commentWorksRes.data) { + if (commentWorksRes.data?.msg) { + commentRemark = commentWorksRes.data.msg; + } else { + commentRemark = commentWorksRes.data.toast; + } + } else { + commentRemark = '评论完成'; + } + + // 创建互动记录 + this.createInteractionRecord( + userInfo.id, + account, + { + worksId: works.dataId, + worksTitle: works.title, + worksCover: works.coverUrl, + }, + commentRemark, + thisCommentContent, + isLike, + isCollect, // 收藏状态设为0 + ); + console.log('------ 作品处理完成:', works.dataId); + } + + console.log('------ 所有作品处理完成'); + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.ReplyCommentEnd, + status: 1, + }); + + return true; + } catch (error) { + console.error('------ 任务执行出错:', error); + scheduleEvent({ + tag: AutorWorksInteractionScheduleEvent.Error, + status: -1, + error, + }); + return false; + } + + // 清除缓存 + cacheData.delete(); + } + + /** + * 添加作品回复评论的任务到队列 + * @param account + * @param dataId + */ + async addReplyQueue( + account: AccountModel, + worksList: WorkData[], + option: { + commentContent: string; // 评论内容 + }, + autoRun: AutoRunModel, + ): Promise<{ + status: 0 | 1; + message?: string; + }> { + // 查看缓存,有的就不执行 + if (AutoInteractionCache.getInfo()) { + sysNotice('请勿重复执行', `有正在执行的任务,任务ID:${autoRun.id}`); + + return { + status: 0, + message: '有正在执行的任务,请勿重复执行', + }; + } + + // 创建任务执行记录 + const recordData = await this.autoRunService.createAutoRunRecord(autoRun); + + // 添加到队列 + this.interactionQueue.add(() => { + this.autorInteraction( + account, + worksList, + option as any, + (e: { + tag: AutorWorksInteractionScheduleEvent; + status: -1 | 0 | 1; + error?: any; + }) => { + if (e.tag === AutorWorksInteractionScheduleEvent.Start) { + sysNotice('自动互动任务执行开始', `任务ID:${autoRun.id}`); + } + + if (e.tag === AutorWorksInteractionScheduleEvent.End) { + sysNotice('自动互动任务执行结束', `任务ID:${autoRun.id}`); + this.autoRunService.updateAutoRunRecordStatus( + recordData.id, + AutoRunRecordStatus.SUCCESS, + ); + } + + if (e.tag === AutorWorksInteractionScheduleEvent.Error) { + sysNotice('自动互动回复任务-错误!!!', `任务ID:${autoRun.id}`); + this.autoRunService.updateAutoRunRecordStatus( + recordData.id, + AutoRunRecordStatus.FAIL, + ); + } + }, + ); + }); + + return { + status: 1, + }; + } + + // 自动互动 + @Inject(UserService) + private readonly userService!: UserService; + @Inject(AccountService) + private readonly accountService!: AccountService; + + // 获取自动互动列表 + async getAutorInteractionList(account: any, worksList: any, option: any) { + console.log('------ server option.commentContent ----', option.commentContent); + return await this.autorInteraction( + account, + worksList, + { + commentContent: option.commentContent || null, + platform: option.accountType, // 平台 + likeProb: 999, // 点赞概率 + collectProb: 999, // 收藏概率 + commentProb: 999, // 评论概率 + commentType: option.commentContent?'custom' : 'ai', // 评论类型 + }, + (e: { + tag: AutorWorksInteractionScheduleEvent; + status: -1 | 0 | 1; + error?: any; + }) => { + console.log('------ e', e); + }, + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/PlatformBase.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/PlatformBase.ts new file mode 100644 index 000000000..567b6aa0c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/PlatformBase.ts @@ -0,0 +1,261 @@ +/* + * @Author: nevin + * @Date: 2025-02-07 09:48:29 + * @LastEditTime: 2025-03-24 15:14:58 + * @LastEditors: nevin + * @Description: 平台主类 + */ +import { + AccountInfoTypeRV, + CommentData, + CookiesType, + DashboardData, + IAccountInfoParams, + IGetLocationDataParams, + IGetLocationResponse, + IGetMixListResponse, + IGetTopicsParams, + IGetTopicsResponse, + IGetUsersParams, + IGetUsersResponse, + ResponsePageInfo, + StatisticsData, + VideoCallbackType, + WorkData, +} from './plat.type'; +import { PublishVideoResult } from './module'; +import { PlatType } from '../../../commont/AccountEnum'; +import { AccountModel } from '../../db/models/account'; +import { VideoModel } from '../../db/models/video'; +import { ImgTextModel } from '../../db/models/imgText'; + +/** + * 平台基类,所有平台都该继承这个类 + */ +export abstract class PlatformBase { + // 平台类型 + protected readonly type: PlatType; + + constructor(type: PlatType) { + this.type = type; + } + + /** + * 平台登录 + * @param params + */ + abstract login(params?: any): Promise; + + /** + * 登录状态检测 + * @param account + */ + abstract loginCheck(account: AccountModel): Promise<{ + // true=在线,false=离线 + online: boolean; + // 要额外更新的账户数据 + account?: Partial; + }>; + + /** + * 获取该平台的用户信息 + * @param params + */ + abstract getAccountInfo( + params: IAccountInfoParams, + ): Promise; + + /** + * 获取统计数据 + * @param account 账号 + */ + abstract getStatistics(account: AccountModel): Promise; + + /** + * 获取账户的看板数据 + * @param account 账号 + * @param time + */ + abstract getDashboard( + account: AccountModel, + time: string[], + ): Promise; + + /** + * 点赞 + * @param account 账户 + * @param dataId 数据ID + * @param option 配置项 + */ + abstract dianzanDyOther( + account: AccountModel, + dataId: string, + option?: any, + ): Promise; + + /** + * 收藏 + * @param account 账户 + * @param pcursor 分页游标 + */ + abstract shoucangDyOther( + account: AccountModel, + pcursor?: string, + ): Promise; + + /** + * 获取作品列表 + * @param account 账户 + * @param pcursor 分页游标 + */ + abstract getWorkList( + account: AccountModel, + pcursor?: string, + ): Promise<{ + list: WorkData[]; + pageInfo: ResponsePageInfo; + }>; + + /** + * 搜索作品 + * @param account 账户 + * @param qe 搜索关键词 + * @param pageInfo 分页信息 + */ + abstract getsearchNodeList( + account: AccountModel, + qe?: string, + pageInfo?: any, + ): Promise<{ + list: WorkData[]; + pageInfo: ResponsePageInfo; + }>; + + /** + * 获取某个作品的数据 + * @param dataId 作品的唯一标识 + */ + abstract getWorkData(dataId: string): Promise; + + /** + * 获取评论列表 + * @param account + * @param dataId + * @param pcursor + */ + abstract getCommentList( + account: AccountModel, + workData: WorkData, + pcursor?: string, + ): Promise<{ + list: CommentData[]; + pageInfo: ResponsePageInfo; + }>; + + /** + * 获取他人作品的二级评论列表 + * @param account + * @param dataId + * @param pcursor + */ + abstract getCreatorSecondCommentListByOther( + account: AccountModel, + workData: WorkData, + root_comment_id: string, + pcursor?: string, + ): Promise; + + /** + * 获取评论列表 + * @param account + * @param dataId + * @param pcursor + */ + abstract getCreatorCommentListByOther( + account: AccountModel, + workData: WorkData, + pcursor?: string, + ): Promise<{ + list: CommentData[]; + orgList?: any; + pageInfo: ResponsePageInfo; + }>; + + /** + * 创建评论 + */ + abstract createComment( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ): Promise; + + /** + * 创建评论 + */ + abstract createCommentByOther( + account: AccountModel, + dataId: string, // 作品ID + content: string, + authorId?: string, + ): Promise; + + /** + * 回复评论 + */ + abstract replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + videoAuthId?: string; // 视频作者ID + }, + ): Promise; + + /** + * 回复评论 + */ + abstract replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ): Promise; + + /** + * 视频发布 + */ + abstract videoPublish( + params: VideoModel, + // 获取发布进度的回调函数 + callback: VideoCallbackType, + ): Promise; + + /** + * 图文发布,有些平台不支持图文发布 + */ + imgTextPublish(params: ImgTextModel): Promise { + throw `平台${this.type}不支持图文发布`; + } + + // 获取合集 + getMixList(cookie: CookiesType): Promise { + throw `平台${this.type}不支持获取合集`; + } + + // 话题数据获取 + abstract getTopics(params: IGetTopicsParams): Promise; + + // 获取位置数据 + abstract getLocationData( + params: IGetLocationDataParams, + ): Promise; + + // 获取@用户数据 + abstract getUsers(params: IGetUsersParams): Promise; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/index.ts new file mode 100644 index 000000000..670872433 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/index.ts @@ -0,0 +1,380 @@ +/* + * @Author: nevin + * @Date: 2025-02-06 15:57:02 + * @LastEditTime: 2025-03-24 15:15:04 + * @LastEditors: nevin + * @Description: + */ +import { AccountModel } from '../../db/models/account'; +import { PlatformBase } from './PlatformBase'; +import kwai from './platforms/Kwai'; +import xhs from './platforms/xhs'; +import douyin from './platforms/douyin'; +import wxSph from './platforms/wxSph'; +import { + IAccountInfoParams, + IGetLocationDataParams, + IGetUsersParams, + WorkData, +} from './plat.type'; +import { PublishVideoResult } from './module'; +import { VideoModel } from '../../db/models/video'; +import { PubItemVideo } from './pub/PubItemVideo'; +import { PlatType } from '../../../commont/AccountEnum'; +import { PubItemImgText } from './pub/PubItemImgText'; +import { ImgTextModel } from '../../db/models/imgText'; +import { EtEvent } from '../../global/event'; + +class PlatController { + // 所有平台 + private readonly platforms = new Map(); + + constructor() { + this.platforms.set(PlatType.KWAI, kwai); + this.platforms.set(PlatType.Xhs, xhs); + this.platforms.set(PlatType.Douyin, douyin); + this.platforms.set(PlatType.WxSph, wxSph); + } + + // 获取平台类实例 + private getPlatform(type: PlatType) { + const platform = this.platforms.get(type); + if (!platform) console.warn(`没有这个平台:${type}`); + + return this.platforms.get(type); + } + + /** + * 登录某个平台 + * @param type 平台 + * @param params 参数 + */ + public async platlogin(type: PlatType, params?: any) { + const platform = this.platforms.get(type)!; + const res = await platform.login(params); + if (!res || !res.loginCookie) return null; + // 获取账户信息 + const info = await platform.getAccountInfo({ + cookies: JSON.parse(res.loginCookie), + }); + if (!!info) res.fansCount = info.fansCount || 0; + return res; + } + + /** + * 平台登录检测 + * @param type 平台 + * @param account + */ + public async platLoginCheck(type: PlatType, account: AccountModel) { + const platform = this.platforms.get(type)!; + return await platform.loginCheck(account); + } + + /** + * 发布视频,支持发布到多个平台 + * @param videoModels 视频记录数据 + * @param accountModels 该用户的账号记录数据 + */ + public async videoPublish( + videoModels: VideoModel[], + accountModels: AccountModel[], + ) { + // 总发布记录状态更新 + const tasks: Promise[] = []; + for (const videoModel of videoModels) { + const platform = this.getPlatform(videoModel.type); + if (platform) { + const pubItemVideo = new PubItemVideo( + accountModels.find((v) => v.id === videoModel.accountId)!, + videoModel, + platform, + ); + + EtEvent.emit('ET_TRACING_VIDEO_PUL', { + accountId: videoModel.accountId, + dataId: videoModel.dataId, + desc: '发布成功!', + }); + + tasks.push(pubItemVideo.publishVideo()); + } + } + return await Promise.all(tasks); + } + + /** + * 发布图文,支持发布到多个平台 + * @param imgTextModels 图文记录数据 + * @param accountModels 该用户的账号记录数据 + */ + public async imgTextPublish( + imgTextModels: ImgTextModel[], + accountModels: AccountModel[], + ) { + // 总发布记录状态更新 + const tasks: Promise[] = []; + for (const videoModel of imgTextModels) { + const platform = this.getPlatform(videoModel.type); + if (platform) { + const pubItemVideo = new PubItemImgText( + accountModels.find((v) => v.id === videoModel.accountId)!, + videoModel, + platform, + ); + tasks.push(pubItemVideo.publishImgText()); + } + } + return await Promise.all(tasks); + } + + /** + * 获取某个平台的话题数据 + * @param account + * @param keyword + */ + public async getTopic(account: AccountModel, keyword: string) { + const platform = this.platforms.get(account.type)!; + return await platform.getTopics({ + keyword, + account, + }); + } + + /** + * 获取某个平台的账户信息 + * @param type 平台 + * @param params 参数 + */ + public async getAccountInfo(type: PlatType, params: IAccountInfoParams) { + const platform = this.platforms.get(type)!; + return await platform.getAccountInfo(params); + } + + /** + * 获取某个平台的账户统计数据 + * @param account 账户 + */ + public async getStatistics(account: AccountModel) { + const platform = this.platforms.get(account.type)!; + return await platform.getStatistics(account); + } + + /** + * 获取某个平台的账户面板数据 + * @param account 账户 + * @param time + */ + public async getDashboard(account: AccountModel, time?: [string, string]) { + const platform = this.platforms.get(account.type)!; + return await platform.getDashboard(account, time || []); + } + + // 获取位置数据 + public async getLocationData(params: IGetLocationDataParams) { + const platform = this.platforms.get(params.account!.type)!; + return await platform.getLocationData({ + ...params, + cookie: JSON.parse(params.account!.loginCookie), + }); + } + + // 获取用户数据 + public async getUsers(params: IGetUsersParams) { + const platform = this.platforms.get(params.account!.type)!; + return await platform.getUsers(params); + } + + /** + * 点赞 + * @param account + * @param dataId + * @param option + */ + public async dianzanDyOther( + account: AccountModel, + dataId: string, + option?: any, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.dianzanDyOther(account, dataId, option); + } + + /** + * 获取作品列表 + * @param account + * @param pcursor + */ + public async shoucangDyOther(account: AccountModel, pcursor?: string) { + const platform = this.platforms.get(account.type)!; + return await platform.shoucangDyOther(account, pcursor); + } + + /** + * 获取作品列表 + * @param account + * @param pcursor + */ + public async getWorkList(account: AccountModel, pcursor?: string) { + const platform = this.platforms.get(account.type)!; + return await platform.getWorkList(account, pcursor); + } + + /** + * 搜索作品列表 + * @param account + * @param qe + * @param pageInfo + */ + public async getsearchNodeList( + account: AccountModel, + qe?: string, + pageInfo?: any, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.getsearchNodeList(account, qe, pageInfo); + } + + /** + * 获取评论列表 + * @param account + * @param data + * @param pcursor + */ + public async getCommentList( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.getCommentList(account, data, pcursor); + } + + /** + * 获取评论列表 + * @param account + * @param data + * @param pcursor + */ + public async getCreatorCommentListByOther( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.getCreatorCommentListByOther(account, data, pcursor); + } + + // 获取合集 + public async getMixList(account: AccountModel) { + const platform = this.platforms.get(account.type)!; + return await platform.getMixList(JSON.parse(account.loginCookie)); + } + + /** + * 获取二级评论列表 + * @param account + * @param data + * @param root_comment_id + * @param pcursor + */ + public async getCreatorSecondCommentListByOther( + account: AccountModel, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.getCreatorSecondCommentListByOther( + account, + data, + root_comment_id, + pcursor, + ); + } + + /** + * 创建评论 + * @param account + * @param dataId + * @param content + */ + public async createComment( + account: AccountModel, + dataId: string, + content: string, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.createComment(account, dataId, content); + } + + /** + * 创建评论 + * @param account + * @param dataId + * @param content + */ + public async createCommentByOther( + account: AccountModel, + dataId: string, + content: string, + authorId?: string, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.createCommentByOther( + account, + dataId, + content, + authorId, + ); + } + + /** + * 回复评论 + * @param account + * @param commentId + * @param content + * @param option + */ + public async replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + videoAuthId?: string; // 视频作者ID + }, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.replyCommentByOther( + account, + commentId, + content, + option, + ); + } + + /** + * 回复评论 + * @param account + * @param commentId + * @param content + * @param option + */ + public async replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + const platform = this.platforms.get(account.type)!; + return await platform.replyComment(account, commentId, content, option); + } +} + +const platController = new PlatController(); +export default platController; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/module.ts new file mode 100644 index 000000000..d70c24db1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/module.ts @@ -0,0 +1,52 @@ +/* + * @Author: nevin + * @Date: 2025-02-07 09:48:29 + * @LastEditTime: 2025-02-08 20:13:55 + * @LastEditors: nevin + * @Description: + */ +import { PlatType } from '../../../commont/AccountEnum'; + +import { PubStatus } from '../../../commont/publish/PublishEnum'; + +interface IProperty { + code: number; + msg: string; + dataId: string; + previewVideoLink: string; +} + +export interface IVideoPubOtherData { + [PlatType.Xhs]?: { + // 预览需要 + xsec_token: string; + xsec_source: string; + }; +} + +export class PublishVideoResult { + // 0=失败 1=成功 + code: number; + // 提示信息 + msg: string; + // 数据ID + dataId?: string; + // 预览视频地址 + previewVideoLink?: string; + // 发布状态,可选值 + pubStatus?: PubStatus; + + constructor( + { code, msg, dataId, previewVideoLink }: IProperty = { + code: 1, + msg: '发布成功!', + dataId: '', + previewVideoLink: '', + }, + ) { + this.code = code; + this.msg = msg; + this.dataId = dataId; + this.previewVideoLink = previewVideoLink; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/plat.type.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/plat.type.ts new file mode 100644 index 000000000..bc1d7d508 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/plat.type.ts @@ -0,0 +1,179 @@ +/* + * @Author: nevin + * @Date: 2025-02-07 09:48:29 + * @LastEditTime: 2025-03-23 23:41:38 + * @LastEditors: nevin + * @Description: + */ +import { AccountModel } from '../../db/models/account'; + +export type CookiesType = Electron.Cookie[]; + +export interface ResponsePageInfo { + count?: number; + hasMore: boolean; + pcursor?: string; +} + +// 获取平台账户信息入参 +export interface IAccountInfoParams { + cookies: CookiesType; +} + +// 获取平台账户信息返回值 +export type AccountInfoTypeRV = Partial | null; + +// 获取平台账户统计信息返回值 +export type StatisticsData = { + workCount?: number; + readCount?: number; + fansCount?: number; + income?: number; +}; + +// 获取平台账户统计信息返回值 +export type DashboardData = { + fans: number; + read: number; + comment: number; + like: number; + collect: number; + forward: number; + time?: string; +}; + +// 获取某个作品的数据返回值 +export type WorkData = { + dataId: string; + readCount?: number; + likeCount?: number; + collectCount?: number; + forwardCount?: number; + commentCount?: number; // 评论数量 + income?: number; // 收入 + title?: string; + desc?: string; + coverUrl?: string; + videoUrl?: string; + createTime?: string; + option?: { + xsec_token: string; + }; + author?: { + name: string; + id?: string; + avatar: string; + }; + data?: any; +}; + +// 评论 +export type CommentData = { + userId: string; + dataId: string; + commentId: string; + parentCommentId?: string; // 上级评论ID + content: string; + likeCount?: number; // 点赞次数 + nikeName?: string; + headUrl?: string; + data?: any; // 原数据 + subCommentList: CommentData[]; // 子评论 +}; + +// 视频发布进度回调函数类型 +export type VideoCallbackType = (progress: number, msg?: string) => void; + +// 微信视频号活动 +export interface WxSphEvent { + eventCreatorNickname: string; + eventTopicId: string; + eventName: string; +} + +// 获取用户参数 +export interface IGetUsersParams { + keyword: string; + account: AccountModel; + page: number; +} + +// 获取用户返回值 +export interface IGetUsersResponse { + status: number; + data?: IUsersItem[]; +} + +// 用户数据 +export interface IUsersItem { + image: string; + id: string; + name: string; + des?: string; + unique_id?: string; + follower_count?: number; +} + +// 获取话题返回值 +export interface IGetTopicsResponse { + status: number; + data?: ITopicsItem[]; +} + +// 话题数据 item +export interface ITopicsItem { + view_count: number; + name: string; + id: string | number; +} + +// 话题参数 +export interface IGetTopicsParams { + keyword: string; + account: AccountModel; +} + +// 话题参数 +export interface IGetLocationDataParams { + keywords: string; + latitude: number; + longitude: number; + cityName: string; + cookie?: Electron.Cookie[]; + account?: AccountModel; +} + +// 地点数据 +export interface ILocationDataItem { + // 地点名称 + name: string; + // 简单地址简介 + simpleAddress: string; + // 地址ID + id: string; + // 小红书特有 + poi_type?: number; + latitude: number; + longitude: number; + // 市 + city: string; +} + +// 获取地点数据返回值 +export interface IGetLocationResponse { + status: number; + data?: ILocationDataItem[]; +} + +export interface IMixItem { + id: string; + name: string; + coverImg: string; + // 作品数量 + feedCount: number; +} +// 获取合集返回值 +export interface IGetMixListResponse { + status: number; + data: IMixItem[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/KwaiPubListener.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/KwaiPubListener.ts new file mode 100644 index 000000000..5206862c8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/KwaiPubListener.ts @@ -0,0 +1,123 @@ +import { kwaiPub } from '../../../../plat/Kwai'; +import { EtEvent } from '../../../../global/event'; +import windowOperate from '../../../../util/windowOperate'; +import { SendChannelEnum } from '../../../../../commont/UtilsEnum'; +import { sleep } from '../../../../../commont/utils'; +import { VideoModel } from '../../../../db/models/video'; +import { AccountModel } from '../../../../db/models/account'; +import { PubStatus } from '../../../../../commont/publish/PublishEnum'; + +/** + * 快手发布监听器 + * 快手发布视频后会调用这个静态类的方法监听该条发布的视频是否完成,如果完成那么不会再监听 + */ +export default class KwaiPubListener { + private constructor() {} + + // 是否已经开始启动,防止重复启动 + static started = false; + // key=cookie,val=侦听的id + static listenMap: Map = new Map(); + + // 处理逻辑 + static async handle(cookie: string) { + const ids = KwaiPubListener.listenMap.get(cookie)!; + const works = await kwaiPub.refreshWorks(JSON.parse(cookie), ids); + works.data.data.list.find((v) => { + if (v.workId) { + const previewVideoLink = `https://www.kuaishou.com/short-video/${v.workId}`; + EtEvent.emit('ET_PUBLISH_UPDATE_VIDEO_PUL_BY_DATAID', { + previewVideoLink, + status: PubStatus.RELEASED, + dataId: `${v.id}`, + }); + windowOperate.sendRenderMsg(SendChannelEnum.VideoAuditFinish, { + previewVideoLink, + dataId: v.id, + }); + const newIds = ids.filter((id) => id != v.id); + if (newIds.length === 0) { + KwaiPubListener.listenMap.delete(cookie); + } else { + KwaiPubListener.listenMap.set(cookie, newIds); + } + } + }); + } + + static async getVideoRecord() { + return new Promise((resolve) => { + EtEvent.emit('ET_PUBLISH_VIDEO_AUDIT_LIST', (list: VideoModel[]) => { + resolve(list); + }); + }); + } + + static async getAccountById(ids: number[]) { + return new Promise((resolve) => { + EtEvent.emit( + 'ET_ACCOUNT_GET_LIST_BY_IDS', + ids, + (list: AccountModel[]) => { + resolve(list); + }, + ); + }); + } + + /** + * 开始监听 + */ + static async start(cookie?: string, id?: number) { + if (cookie) { + if (!KwaiPubListener.listenMap.has(cookie)) { + KwaiPubListener.listenMap.set(cookie, []); + } + KwaiPubListener.listenMap.get(cookie)?.push(id!); + } else { + const videoModels: VideoModel[] = await KwaiPubListener.getVideoRecord(); + if (videoModels && videoModels.length === 0) return; + const accountIds = videoModels.map((v) => { + return v.accountId; + }); + if (accountIds.length === 0) return; + const accounts: AccountModel[] = + await KwaiPubListener.getAccountById(accountIds); + if (accounts.length === 0) return; + + accounts.map((v) => { + const cookie = v.loginCookie; + if (!KwaiPubListener.listenMap.has(cookie)) { + KwaiPubListener.listenMap.set( + cookie, + videoModels + .filter((k) => k.accountId === v.id) + .map((v) => { + return parseInt(v.dataId); + }), + ); + } + }); + } + if (KwaiPubListener.started) return; + + KwaiPubListener.started = true; + while (true) { + if (KwaiPubListener.listenMap.size === 0) { + break; + } + try { + const tasks: Promise[] = []; + for (const [cookie] of KwaiPubListener.listenMap) { + tasks.push(this.handle(cookie)); + } + await Promise.all(tasks); + } catch (e) { + console.error('快手审核作品查询错误:', e); + break; + } + await sleep(3000); + } + KwaiPubListener.started = false; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/index.ts new file mode 100644 index 000000000..71a3b84fc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/Kwai/index.ts @@ -0,0 +1,586 @@ +/* + * @Author: nevin + * @Date: 2025-02-19 17:54:53 + * @LastEditTime: 2025-03-25 13:18:41 + * @LastEditors: nevin + * @Description: + */ +import { PlatformBase } from '../../PlatformBase'; +import { + AccountInfoTypeRV, + CommentData, + CookiesType, + DashboardData, + IAccountInfoParams, + IGetLocationDataParams, + IGetTopicsParams, + IGetTopicsResponse, + IGetUsersParams, + VideoCallbackType, + WorkData, +} from '../../plat.type'; +import { PublishVideoResult } from '../../module'; +import { kwaiPub } from '../../../../plat/Kwai'; +import { IRequestNetResult } from '../../../../plat/requestNet'; +import { IKwaiUserInfoResponse } from '../../../../plat/Kwai/kwai.type'; +import { PlatType } from '../../../../../commont/AccountEnum'; +import { AccountModel } from '../../../../db/models/account'; +import dayjs from 'dayjs'; +import { VideoModel } from '../../../../db/models/video'; +import { + PubStatus, + VisibleTypeEnum, +} from '../../../../../commont/publish/PublishEnum'; +import KwaiPubListener from './KwaiPubListener'; + +export class Kwai extends PlatformBase { + constructor() { + super(PlatType.KWAI); + } + + /** + * 快手账户登录 + */ + async login() { + const req = await kwaiPub.login(); + const userInfo = await this.formatUserInfo(req.userInfo, req.cookies); + if (!userInfo) return null; + userInfo.loginCookie = JSON.stringify(req.cookies); + return userInfo; + } + + /** + * 格式化用户信息 + * @param req + * @param cookies + */ + async formatUserInfo( + req: IRequestNetResult, + cookies: Electron.Cookie[], + ) { + const { data } = req.data; + const res = await kwaiPub.getHomeInfo(cookies); + if (!data) return null; + return { + userId: '', + loginCookie: '', + type: this.type, + uid: `${data.userInfo.userId}` || '', + account: `${data.userInfo.userId}` || '', + avatar: data.userInfo.avatar || '', + nickname: data.userInfo.name || '', + fansCount: res.data.data.fansCnt, + }; + } + + /** + * 获取账号信息 + * @param params + */ + async getAccountInfo(params: IAccountInfoParams): Promise { + const res = await kwaiPub.getAccountInfo(params.cookies); + return await this.formatUserInfo(res, params.cookies); + } + + async videoPublish( + params: VideoModel, + callback: VideoCallbackType, + ): Promise { + return new Promise(async (resolve) => { + const result = await kwaiPub + .pubVideo({ + proxy: params.proxyIp || '', + publishTime: params.timingTime?.getTime(), + mentions: params.mentionedUserInfo.map((v) => v.label), + topics: params.topics || [], + videoPath: params.videoPath || '', + coverPath: params.coverPath || '', + cookies: params.cookies!, + desc: params.desc + params.topics.map((v) => `#${v}`).join(' '), + callback, + photoStatus: + params.visibleType === VisibleTypeEnum.Public + ? 1 + : params.visibleType === VisibleTypeEnum.Private + ? 2 + : 4, + poiInfo: params.location + ? { + poiId: params.location.id, + latitude: `${params.location.latitude}`, + longitude: `${params.location.longitude}`, + } + : undefined, + }) + .catch((e) => { + resolve({ + code: 0, + msg: e, + }); + }); + if (!result || !result.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试', + }); + + KwaiPubListener.start(JSON.stringify(params.cookies), +result.publishId); + return resolve({ + code: 1, + msg: '发布成功', + dataId: result.publishId, + previewVideoLink: result.shareLink, + pubStatus: PubStatus.Audit, + }); + }); + } + + async getStatistics(account: AccountModel) { + const res = await kwaiPub.getHomeInfo(JSON.parse(account.loginCookie)); + return { + fansCount: res?.data?.data?.fansCnt, + workCount: 0, + }; + } + + async getDashboard(account: AccountModel, time: string[] = []) { + const res = await kwaiPub.getHomeOverview(JSON.parse(account.loginCookie)); + const dashboard: DashboardData[] = []; + const startTime = new Date(time[0]).getTime() - 86400001; + const endTime = new Date(time[1]).getTime() + 1; + + res?.data?.data?.basicData?.map((v1, i1) => { + v1.trendData.map((v2, i2) => { + const currTime = dayjs(v2.date, 'YYYYMMDD').valueOf(); + if (currTime >= startTime && currTime <= endTime) { + if (!dashboard[i2]) + dashboard[i2] = { + comment: 0, + fans: 0, + forward: 0, + like: 0, + read: 0, + time: '', + collect: 0, // TODO: 获取收藏数据 + }; + const item = dashboard[i2]; + + item.time = v2.date; + if (v1.tab === 'LIKE') { + item.like = v2.count; + } else if (v1.tab === 'PURE_INCREASE_FAN') { + item.fans = v2.count; + } else if (v1.tab === ' COMMENT') { + item.comment = v2.count; + } else if (v1.tab === 'SHARE') { + item.forward = v2.count; + } else if (v1.tab === 'PLAY') { + item.read = v2.count; + } + } + }); + }); + + return dashboard.filter(Boolean); + } + + /** + * 获取作品列表 + * @param pageInfo + * @returns + */ + async getWorkList(account: AccountModel, pcursor?: string) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.getPhotoList(cookie, Number(pcursor)); + + const photoList = res.data.data.photoList; + const list: WorkData[] = photoList.map((v) => { + return { + dataId: v.photoId, + readCount: v.playCount, + likeCount: v.likeCount, + commentCount: v.commentCount, + title: v.title, + coverUrl: v.cover, + }; + }); + + return { + list, + pageInfo: { + hasMore: !!res.data.data.pcursor, + count: res.data.data.totalCount, + pcursor: res.data.data.pcursor + '', + }, + }; + } + + /** + * 搜索作品列表 + * @param pageInfo + * @returns + */ + async getsearchNodeList( + account: AccountModel, + qe: string, + pageInfo?: any, + ): Promise<{ + list: WorkData[]; + orgList: any[]; + pageInfo: any; + }> { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.getsearchNodeList(cookie, qe, pageInfo); + console.log('----------- getsearchNodeList --- res: ', res.data); + const photoList = res.data.data?.visionSearchPhoto.feeds || []; + // console.log('----------- getsearchNodeList --- photoList: ', photoList[0]); + // const list: WorkData[] = photoList.map((v) => { + // return { + // dataId: v.photo.id, + // readCount: v.photo.viewCount, + // likeCount: v.photo.likeCount, + // commentCount: v.photo.commentCount, + // title: v.photo.caption, + // coverUrl: v.photo.coverUrl, + // }; + // }); + const list: WorkData[] = []; + for (const s of photoList) { + list.push({ + dataId: s.photo.id, + readCount: s.photo.viewCount, + likeCount: s.photo.likeCount, + collectCount: s.photo.collectCount, + commentCount: s.photo.commentCount, + title: s.photo.caption, + coverUrl: s.photo.coverUrl, + option: { + xsec_token: s.xsec_token || '', + }, + author: { + name: s.author?.name, + id: s.author?.id, + avatar: s.author?.headerUrl, + }, + data: s, + }); + } + + return { + list: list, + orgList: res.data.data?.visionSearchPhoto, + pageInfo: { + hasMore: photoList.length > 1 ? true : false, + count: res.data.data?.visionSearchPhoto.length, + pcursor: Number(res.data.data?.visionSearchPhoto?.pcursor) + 1 || 1, + }, + }; + } + + /** + * TODO: 未实现 + * @returns + * @param dataId + */ + async getWorkData(dataId: string) { + return { + dataId: '', + }; + } + + async getCommentList( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.getCommentList( + cookie, + data.dataId, + pcursor ? Number.parseInt(pcursor) : undefined, + ); + + const list: CommentData[] = []; + for (const v of res.data.data.list) { + const subList: CommentData[] = []; + + if (!!v.subCommentCount) { + const subRes = await kwaiPub.getSubCommentList( + cookie, + data.dataId, + v.commentId, + ); + + for (const v1 of subRes.data.data.list) { + subList.push({ + userId: v1.authorId + '', + dataId: v1.photoId + '', + commentId: v1.commentId + '', + content: v1.content, + likeCount: undefined, + nikeName: v1.headurl, + headUrl: v1.headurl, + data: v1, + subCommentList: [], + }); + } + } + + list.push({ + userId: v.authorId + '', + dataId: v.photoId + '', + commentId: v.commentId + '', + parentCommentId: undefined, + content: v.content, + likeCount: undefined, + nikeName: v.headurl, + headUrl: v.headurl, + data: v, + subCommentList: subList, + }); + } + + return { + list: list, + pageInfo: { + count: 0, + pcursor: res.data.data.pcursor + '', + hasMore: !!res.data.data.pcursor, + }, + }; + } + + /** + * 获取其他视频评论列表 + * @param account + * @param data + * @param pcursor + * @returns + */ + async getCreatorCommentListByOther( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.getVideoCommentList(cookie, data.dataId, pcursor); + + const list: CommentData[] = []; + for (const v of res.data.data.visionCommentList?.rootComments || []) { + list.push({ + userId: v.authorId + '', + dataId: data.dataId + '', + commentId: v.commentId + '', + parentCommentId: undefined, + content: v.content, + likeCount: v.likedCount, + nikeName: v.authorName, + headUrl: v.headurl, + data: v, + subCommentList: v.subComments, + }); + } + + return { + list: list, + pageInfo: { + count: 0, + pcursor: res.data.data.pcursor + '', + hasMore: !!res.data.data.pcursor, + }, + }; + } + + getCreatorSecondCommentListByOther( + account: AccountModel, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ): Promise { + return new Promise((resolve, reject) => {}); + } + + // 创建其他视频评论 + async createCommentByOther( + account: AccountModel, + dataId: string, // 作品ID + content: string, + authorId?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.videoCommentByOther( + cookie, + dataId, + content, + authorId, + ); + console.log('------ kaishou createComment res ----', res); + + return res.data; + } + + // 回复其他评论 + async replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + videoAuthId?: string; // 视频作者ID + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + + const res = await kwaiPub.replyCommentByOther(cookie, content, { + photoId: option.dataId, + replyToCommentId: commentId, + replyTo: option.comment.authorId, + photoAuthorId: option.videoAuthId, + }); + + return res; + } + + async createComment( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.commentAdd(cookie, content, { + photoId: dataId, + }); + console.log('------ kaishou createComment res ----', res); + + return false; + } + + /** + * 回复评论 + * @param account + * @param commentId + * @param content + * @param option + * @returns + */ + async replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + + const res = await kwaiPub.commentAdd(cookie, content, { + photoId: option.dataId, + replyToCommentId: Number.parseInt(commentId), + replyTo: option.comment.authorId, + }); + + if (res.status !== 200 || res.data.result !== 1) return false; + return false; + } + + async loginCheck(account: AccountModel) { + let online = false; + try { + const res = await kwaiPub.getAccountInfo(JSON.parse(account.loginCookie)); + online = !(res?.status !== 200 || !res?.data?.data?.userInfo?.userId); + } catch (e) { + console.warn('快手登录状态检测错误:', e); + online = false; + } + return { + online, + }; + } + + async getTopics({ + keyword, + account, + }: IGetTopicsParams): Promise { + const res = await kwaiPub.getTopics({ + keyword, + cookies: JSON.parse(account.loginCookie), + }); + return { + status: res.status, + data: res.data?.data?.tags?.map((v) => { + return { + id: v.tag.id, + name: v.tag.name, + view_count: v.viewCount, + }; + }), + }; + } + + async getUsers(params: IGetUsersParams) { + const res = await kwaiPub.getUsers({ + page: params.page, + cookies: JSON.parse(params.account.loginCookie), + }); + + return { + status: res.status, + data: res.data?.data?.list?.map((v) => { + return { + image: v.headUrl, + id: `${v.userId}`, + name: v.userName, + follower_count: v.fansCount, + }; + }), + }; + } + + async getLocationData(params: IGetLocationDataParams) { + const res = await kwaiPub.getLocations({ + cookies: params.cookie!, + cityName: params.cityName, + keyword: params.keywords, + }); + return { + status: res.status, + data: res.data?.locations?.map((v) => { + return { + name: v.title, + simpleAddress: v.address, + id: `${v.id}`, + latitude: v.latitude, + longitude: v.longitude, + city: v.city, + }; + }), + }; + } + + /** + * 点赞 + */ + async dianzanDyOther( + account: AccountModel, + dataId: string, + option?: any, + ): Promise { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await kwaiPub.dianzanDyOther(cookie, dataId, option); + console.log('------ dianzanDyOther -- Kwai --- res: ', res); + + return res.data; + } + + /** + * 收藏 + */ + shoucangDyOther(account: AccountModel, pcursor?: string): Promise { + return new Promise((resolve, reject) => {}); + } +} + +const kwai = new Kwai(); +export default kwai; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/douyin/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/douyin/index.ts new file mode 100644 index 000000000..fe7dedfaa --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/douyin/index.ts @@ -0,0 +1,695 @@ +/* + * @Author: nevin + * @Date: 2025-02-08 11:40:45 + * @LastEditTime: 2025-03-31 09:42:25 + * @LastEditors: nevin + * @Description: 抖音 + */ +import { PlatformBase } from '../../PlatformBase'; +import { + AccountInfoTypeRV, + CommentData, + CookiesType, + DashboardData, + IAccountInfoParams, + IGetLocationDataParams, + IGetTopicsParams, + IGetTopicsResponse, + IGetUsersParams, + VideoCallbackType, + WorkData, +} from '../../plat.type'; +import { PublishVideoResult } from '../../module'; +import { + DouyinPlatformSettingType, + douyinService, +} from '../../../../plat/douyin'; +import { PlatType } from '../../../../../commont/AccountEnum'; +import { AccountModel } from '../../../../db/models/account'; +import { VisibleTypeEnum } from '../../../../../commont/publish/PublishEnum'; +import { IRequestNetResult } from '../../../../plat/requestNet'; +import { VideoModel } from '../../../../db/models/video'; +import { ImgTextModel } from '../../../../db/models/imgText'; +import { WorkData as WorkDataModel } from '../../../../db/models/workData'; + +export type PubVideoOptin = { + token: string; + cover: string; + topics: string[]; + poiInfo?: { + poiId: string; // "6601136811005708292" + poiName: string; + }; +}; + +export class Douyin extends PlatformBase { + constructor() { + super(PlatType.Douyin); + } + + /** + * 登录 + * @returns + */ + async login() { + try { + const { success, data, error } = await douyinService.loginOrView('login'); + if (!success || !data) { + console.log('Login process failed:', error); + return null; + } + + const userInfo = await douyinService.getUserInfo(data.cookie); + + const loginCookie = + typeof data.cookie === 'string' + ? data.cookie + : JSON.stringify(data.cookie); + + return { + loginCookie, + loginTime: new Date(), + type: this.type, + uid: userInfo.authorId, + account: userInfo.uid, + avatar: userInfo.avatar, + nickname: userInfo.nickname, + token: data.localStorage, + }; + } catch (error) { + console.error('Login process failed:', error); + return null; + } + } + + async loginCheck(account: AccountModel) { + const online = await douyinService.checkLoginStatus(account.loginCookie); + return { + online, + }; + } + + async getAccountInfo(params: IAccountInfoParams): Promise { + const res = await douyinService.getUserInfo(params.cookies); + + return { + type: this.type, + uid: res.authorId, + account: res.authorId, + avatar: res.avatar, + nickname: res.nickname, + fansCount: res.fansCount, + }; + } + + async getStatistics(account: AccountModel) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + + const accountInfo = await douyinService.getUserInfo(cookie); + return { + fansCount: accountInfo.fansCount, + workCount: 0, // TODO: 作品数量 + }; + } + + async getDashboard(account: AccountModel, time: string[] = []) { + const res: DashboardData[] = []; + try { + const ret = await douyinService.getDashboardFunc( + account.loginCookie, + time[0], + time[1], + ); + if (!ret.success) throw new Error('获取三方平台数据失败'); + // console.log('@@@ret.data', ret.data) + for (const item of ret.data) { + res.push({ + time: item.date, + fans: item.zhangfen, + read: item.bofang, + comment: item.pinglun, + like: item.dianzan, + forward: item.fenxiang, + collect: 0, + }); + } + } catch (error) { + console.log('------ getDashboard wxSph ---', error); + } + + return res.reverse(); + } + + /** + * 获取作品列表 + * @param pageInfo + * @returns + */ + async getWorkList(account: AccountModel, pcursor?: string) { + const res = await douyinService.getCreatorItems( + JSON.parse(account.loginCookie), + pcursor, + ); + + const list: WorkData[] = []; + for (const element of res.data.item_info_list) { + list.push({ + dataId: element.item_id, + readCount: undefined, + likeCount: undefined, + collectCount: undefined, + forwardCount: undefined, + commentCount: element.comment_count, + income: undefined, + title: element.title, + desc: undefined, + coverUrl: element.cover_image_url, + videoUrl: undefined, + createTime: element.create_time, + }); + + pcursor = element.cursor; + } + + return { + list: list, + pageInfo: { + count: res.data.total_count, + hasMore: res.data.has_more, + pcursor: pcursor, + }, + }; + } + + /** + * TODO: 未实现 + * @returns + * @param dataId + */ + async getWorkData(dataId: string) { + return { + dataId: '', + }; + } + + /** + * 获取搜索作品列表 + * @param account + * @param data + * @param pcursor + * @returns + */ + async getsearchNodeList(account: AccountModel, qe: string, pageInfo?: any) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + console.log( + 'getsearchNodeList', + pageInfo.count, + pageInfo.pcursor, + pageInfo.postFirstId, + ); + const res = await douyinService.getSearchNodeList(cookie, qe, { + count: pageInfo.count, + pcursor: pageInfo.pcursor, + postFirstId: pageInfo.postFirstId, + }); + + const list: WorkData[] = []; + console.log('------douyin getsearchNodeList res: ', res.data.data); + // console.log('------douyin getsearchNodeList res.data.cursor: ', res.data.cursor); + for (const s of res.data.data) { + const v = s.aweme_info; + list.push({ + dataId: v.aweme_id, + readCount: v.statistics?.digg_count, + likeCount: v.statistics?.digg_count, + collectCount: v.statistics?.collect_count, + commentCount: v.statistics?.comment_count, + title: v.desc, + coverUrl: v.video.cover.url_list[0] || '', + option: { + xsec_token: v.xsec_token || '', + }, + author: { + name: v.author?.nickname, + id: v.author?.uid, + avatar: v.author?.avatar_thumb.url_list[0], + }, + data: v, + }); + } + + // console.log('------douyin getsearchNodeList !!res.data.has_more^^: ', !!res.data.has_more); + return { + list, + orgList: res.data, + pageInfo: { + count: pageInfo.pcursor, + pcursor: res.data.cursor + '', + hasMore: !!res.data.has_more, + }, + }; + } + + /** + * 获取评论列表 + * @param account + * @param data + * @param pcursor + * @returns + */ + async getCommentList( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await douyinService.getCreatorCommentList(cookie, data.dataId, { + count: pcursor ? 20 : undefined, + cursor: pcursor || undefined, + }); + + const list: CommentData[] = []; + + for (const v of res.data.comment_info_list) { + const subList: CommentData[] = []; + if (v.level === 1 && Number.parseInt(v.reply_count) > 0) { + const res2 = await douyinService.getCreatorCommentReplyList( + cookie, + v.comment_id, + { + cursor: 0 + '', + count: 20, + }, + ); + + if (res2.status === 200 && res2.data.status_code === 0) { + for (const element of res2.data.comment_info_list) { + subList.push({ + userId: element.user_info.user_id, + dataId: data.dataId, + commentId: element.comment_id, + content: element.text, + likeCount: Number.parseInt(element.digg_count), + nikeName: element.user_info.screen_name, + headUrl: element.user_info.avatar_url, + data: element, + subCommentList: [], + }); + } + } + } + + list.push({ + userId: v.user_info.user_id, + dataId: data.dataId, + commentId: v.comment_id, + content: v.text, + likeCount: Number.parseInt(v.digg_count), + nikeName: v.user_info.screen_name, + headUrl: v.user_info.avatar_url, + data: v, + subCommentList: subList, + }); + } + + return { + list, + pageInfo: { + count: res.data.total_count, + pcursor: res.data.cursor + '', + hasMore: res.data.has_more, + }, + }; + } + + // 其他人作品评论列表 + async getCreatorCommentListByOther( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res: any = await douyinService.getCreatorCommentListByOther( + cookie, + data.dataId, + { + count: pcursor ? 20 : undefined, + cursor: pcursor || undefined, + }, + ); + + const list: any[] = []; + console.log( + '------ douyinService.getCreatorCommentListByOther', + res.data.comments, + ); + + for (const v of res.data.comments) { + list.push({ + userId: v.user.uid, + dataId: v.aweme_id, + commentId: v.cid, + content: v.text, + likeCount: Number.parseInt(v.digg_count), + nikeName: v.user.nickname, + headUrl: v.user.avatar_thumb.url_list[0], + data: v, + subCommentList: [], + }); + } + + return { + list, + pageInfo: { + count: res.data.total_count, + pcursor: res.data.cursor + '', + hasMore: res.data.has_more, + }, + }; + } + + async dianzanDyOther( + account: AccountModel, + dataId: string, // 作品ID + ): Promise { + const cookie: CookiesType = JSON.parse(account.loginCookie); + try { + const res = await douyinService.creatorDianzanOther(cookie, { + aweme_id: dataId, + item_type: 0, + type: 1, + }); + + return res.status_code === 0; + } catch (error) { + console.log('------ error douyin dianzanDyOther ---- ', error); + return false; + } + } + + async shoucangDyOther( + account: AccountModel, + dataId: string, // 作品ID + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await douyinService.creatorShoucangOther(cookie, { + aweme_id: dataId, + action: 1, + aweme_type: 0, + }); + + console.log('------ res', res); + + return res; + } + + async createCommentByOther( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + console.log('dataIddataId????:', dataId, content); + const res = await douyinService.creatorCommentReplyOther(cookie, { + aweme_id: dataId, + text: content, + one_level_comment_rank: -1, + + // aweme_id: '7498682394024430907', + // comment_send_celltime: 46567, + // comment_video_celltime: 8969, + // one_level_comment_rank: -1, + // paste_edit_method: 'non_paste', + // text: '调', + }); + + console.log('-- 评论 ---- res', res); + + return res; + } + + async replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + console.log('------ replyCommentByOther1', commentId, option.dataId); + + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await douyinService.creatorCommentReplyOther(cookie, { + aweme_id: option.dataId || '', + reply_id: commentId, + text: content, + one_level_comment_rank: 1, + }); + + console.log('------ res', res); + + return res; + } + + async createComment( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await douyinService.creatorCommentReply(cookie, { + comment_Id: '', + item_id: dataId, + text: content, + }); + + return res.status === 200 && res.data.status_code === 0; + } + + async replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + try { + const res = await douyinService.creatorCommentReply(cookie, { + comment_Id: commentId, + item_id: option.dataId!, + text: content, + }); + + return res.status === 200 && res.data.status_code === 0; + } catch (error) { + console.log('------ replyComment ----', error); + return false; + } + } + + pubParamsParse(params: WorkDataModel): DouyinPlatformSettingType { + const douyinParams = params.diffParams![PlatType.Douyin]; + return { + proxyIp: params.proxyIp || '', + userDeclare: douyinParams?.selfDeclare, + activity: douyinParams?.activitys?.map((v) => { + return { + label: v.label, + value: `${v.value}`, + }; + }), + hot_sentence: douyinParams?.hotPoint?.label, + mentionedUserInfo: params.mentionedUserInfo?.map((v) => { + return { + nickName: v.label, + uid: `${v.value}`, + }; + }), + mixInfo: params.mixInfo + ? { + mixId: `${params.mixInfo.value}`, + mixName: params.mixInfo.label, + } + : undefined, + title: params.title || '', + topics: params.topics, + caption: params.desc, + cover: params.coverPath || '', + timingTime: params.timingTime?.getTime(), + // 可见性 + visibility_type: + params.visibleType === VisibleTypeEnum.Public + ? 0 + : params.visibleType === VisibleTypeEnum.Private + ? 1 + : 2, + // 地址 + ...(params.location + ? { + poiInfo: { + poiId: `${params.location.id}`, + poiName: params.location.name, + }, + } + : {}), + }; + } + + async videoPublish( + params: VideoModel, + callback: VideoCallbackType, + ): Promise { + return new Promise(async (resolve) => { + const result = await douyinService + .publishVideoWorkApi( + JSON.stringify(params.cookies), + params?.token, + params.videoPath!, + this.pubParamsParse(params), + callback, + ) + .catch((e) => { + resolve({ + code: 0, + msg: e, + }); + }); + if (!result.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试', + }); + + return resolve({ + code: 1, + msg: '发布成功', + dataId: result.publishId, + previewVideoLink: result.shareLink, + }); + }); + } + + async getTopics({ keyword }: IGetTopicsParams): Promise { + const topicsRes = await douyinService.getTopics({ keyword }); + return { + status: this.getCode(topicsRes), + data: topicsRes?.data?.sug_list?.map((v) => { + return { + id: v.cid, + name: v.cha_name, + view_count: v.view_count, + }; + }), + }; + } + + async getUsers(params: IGetUsersParams) { + const usersRes = await douyinService.getUsers( + JSON.parse(params.account.loginCookie), + params.keyword, + params.page, + ); + + return { + status: this.getCode(usersRes), + data: usersRes?.data?.user_list?.map((v) => { + return { + image: 'https://p26.douyinpic.com/aweme/' + v.avatar_thumb.uri, + id: v.uid, + name: v.nickname, + unique_id: v.unique_id, + des: '', + follower_count: v.follower_count, + }; + }), + }; + } + + async getLocationData(params: IGetLocationDataParams) { + const locationRes = await douyinService.getLocation({ + ...params, + cookie: params.cookie!, + }); + + return { + status: this.getCode(locationRes), + data: locationRes?.data?.poi_list?.map((v) => { + return { + name: v.poi_name, + simpleAddress: v.simple_address_str, + id: v.poi_id, + latitude: v.poi_latitude, + longitude: v.poi_longitude, + city: v.address_info.city, + }; + }), + }; + } + + async imgTextPublish(params: ImgTextModel): Promise { + return new Promise(async (resolve) => { + const result = await douyinService + .publishImageWorkApi( + JSON.stringify(params.cookies), + params?.token, + params.imagesPath, + this.pubParamsParse(params), + ) + .catch((e) => { + resolve({ + code: 0, + msg: e, + }); + }); + if (!result?.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试', + }); + + return resolve({ + code: 1, + msg: '发布成功', + dataId: result.publishId, + previewVideoLink: result.shareLink, + }); + }); + } + + getCreatorSecondCommentListByOther( + account: AccountModel, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ): Promise { + return Promise.resolve(undefined); + } + + getCode(res: IRequestNetResult) { + return res?.data?.status_code === 8 || res?.data?.status_code === 7 + ? 401 + : res?.status; + } + + async getMixList(cookie: CookiesType) { + const mixRes = await douyinService.getMixList(cookie); + return { + status: this.getCode(mixRes), + data: mixRes?.data.mix_list?.map((v) => { + return { + id: v.mix_id, + name: v.mix_name, + coverImg: v.cover_url.url_list[0], + feedCount: v.statis.updated_to_episode, + }; + }), + }; + } +} + +const douyin = new Douyin(); +export default douyin; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/wxSph/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/wxSph/index.ts new file mode 100644 index 000000000..332939218 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/wxSph/index.ts @@ -0,0 +1,465 @@ +/* + * @Author: nevin + * @Date: 2025-02-08 11:40:45 + * @LastEditTime: 2025-03-24 23:35:03 + * @LastEditors: nevin + * @Description: 微信视频号 + */ +import { PlatformBase } from '../../PlatformBase'; +import { + AccountInfoTypeRV, + CommentData, + CookiesType, + DashboardData, + IAccountInfoParams, + IGetLocationDataParams, + IGetTopicsParams, + IGetTopicsResponse, + IGetUsersParams, + ResponsePageInfo, + VideoCallbackType, + WorkData, +} from '../../plat.type'; +import { PublishVideoResult } from '../../module'; +import { shipinhaoService } from '../../../../plat/shipinhao'; +import { PlatType } from '../../../../../commont/AccountEnum'; +import { AccountModel } from '../../../../db/models/account'; +import { CommentInfo } from '../../../../plat/shipinhao/wxShp.type'; +import { IRequestNetResult } from '../../../../plat/requestNet'; +import { VideoModel } from '../../../../db/models/video'; + +export class WxSph extends PlatformBase { + constructor() { + super(PlatType.WxSph); + } + + /** + * 登录 + * @returns + */ + async login() { + try { + const { success, data, error } = + await shipinhaoService.loginOrView('login'); + if (!success || !data) { + console.log('Login process failed:', error); + return null; + } + + const userInfo = await shipinhaoService.getUserInfo(data.cookie); + + const loginCookie = + typeof data.cookie === 'string' + ? data.cookie + : JSON.stringify(data.cookie); + + return { + loginCookie, + loginTime: new Date(), + type: this.type, + uid: userInfo.authorId, + account: userInfo.authorId, + avatar: userInfo.avatar, + nickname: userInfo.nickname, + }; + } catch (error) { + console.error('Login process failed:', error); + return null; + } + } + + async loginCheck(account: AccountModel) { + const online = await shipinhaoService.checkLoginStatus(account.loginCookie); + return { + online, + }; + } + + async getAccountInfo(params: IAccountInfoParams): Promise { + const res = await shipinhaoService.getUserInfo(params.cookies); + + return { + type: this.type, + uid: res.authorId, + account: res.authorId, + avatar: res.avatar, + nickname: res.nickname, + fansCount: res.fansCount, + }; + } + + async getStatistics(account: AccountModel) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const accountInfo = await shipinhaoService.getUserInfo(cookie); + + return { + fansCount: accountInfo.fansCount, + workCount: 0, // TODO: 作品数量 + }; + } + + async getDashboard(account: AccountModel, time: string[] = []) { + const res: DashboardData[] = []; + try { + const cookie: CookiesType = JSON.parse(account.loginCookie); + console.log('time@.@:', time); + const ret = await shipinhaoService.getDashboardFunc( + cookie, + time[0], + time[1], + ); + if (!ret.success) throw new Error('获取三方平台数据失败'); + for (const item of ret.data) { + res.push({ + fans: item.zhangfen, + read: item.bofang, + comment: item.pinglun, + like: item.dianzan, + forward: item.fenxiang, + collect: 0, // TODO: 获取收藏数据 + }); + } + } catch (error) { + console.log('------ getDashboard wxSph ---', error); + } + + return res; + } + + /** + * 获取作品列表 + * @param pageInfo + * @returns + */ + async getWorkList(account: AccountModel, pcursor?: string) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const pageNo = pcursor ? Number.parseInt(pcursor) : 1; + const res = await shipinhaoService.getPostList(cookie, { + pageNo: pageNo, + pageSize: 20, + }); + + const listData: WorkData[] = res.list.map((item) => { + return { + dataId: item.objectId, + commentCount: item.commentCount, + title: item.desc.shortTitle[0]?.shortTitle || '', + desc: item.desc.description, + coverUrl: item.desc.media[0]?.coverUrl || '', + videoUrl: item.desc.media[0]?.url || '', + }; + }); + + return { + list: listData, + pageInfo: { + count: res.totalCount, + hasMore: res.totalCount > res.list.length * pageNo, + pcursor: + res.totalCount > res.list.length * pageNo ? pageNo + 1 + '' : '', + }, + }; + } + + /** + * TODO: 未实现 + * @returns + * @param dataId + */ + async getWorkData(dataId: string) { + return { + dataId: '', + }; + } + + async getCommentList( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await shipinhaoService.getCommentList(cookie, data.dataId); + + const dataList: CommentData[] = []; + + for (const item of res.comment) { + const subDataList: CommentData[] = []; + for (const subItem of item.levelTwoComment) { + subDataList.push({ + userId: subItem.commentNickname, + dataId: subItem.commentId, + commentId: subItem.commentId, + parentCommentId: item.commentId, + content: subItem.commentContent, + nikeName: subItem.commentNickname, + headUrl: subItem.commentHeadurl, + data: subItem, + subCommentList: [], + }); + } + + dataList.push({ + userId: item.commentNickname, + dataId: item.commentId, + commentId: item.commentId, + content: item.commentContent, + nikeName: item.commentNickname, + headUrl: item.commentHeadurl, + data: item, + subCommentList: subDataList, + }); + } + + const pcursorNum = +(pcursor || 0); + + return { + list: dataList, + pageInfo: { + count: res.commentCount, + hasMore: res.commentCount > res.comment.length * pcursorNum, + pcursor: + res.commentCount > res.comment.length * pcursorNum + ? pcursorNum + 1 + '' + : '', + }, + }; + } + + async getCreatorCommentListByOther( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + return { + list: [], + pageInfo: { + count: 0, + pcursor: '', + hasMore: false, + }, + }; + } + + getCreatorSecondCommentListByOther( + account: AccountModel, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ): Promise { + return new Promise((resolve, reject) => {}); + } + + async createCommentByOther( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + return null; + } + + async replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + return null; + } + + async createComment( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await shipinhaoService.createComment(cookie, dataId, content); + return (res.status === 200 || res.status === 201) && res.data.errCode === 0; + } + + async replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId: string; // 作品ID + comment: CommentInfo; // 辅助数据,原数据 + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await shipinhaoService.createComment( + cookie, + option.dataId, + content, + option.comment, + ); + return false; + } + + getCode(res: IRequestNetResult) { + return res.data.errCode === 300334 || res.data.errCode === 300333 + ? 401 + : res.status; + } + + async getUsers(params: IGetUsersParams) { + const usersRes = await shipinhaoService.getUsers( + JSON.parse(params.account.loginCookie), + params.keyword, + params.page, + ); + + return { + status: this.getCode(usersRes), + data: usersRes?.data?.data?.list?.map((v) => { + return { + image: v.headImgUrl, + id: v.username, + name: v.nickName, + }; + }), + }; + } + + async getMixList(cookie: CookiesType) { + const mixRes = await shipinhaoService.getMixList(cookie); + return { + status: this.getCode(mixRes), + data: mixRes?.data?.data?.collectionList?.map((v) => { + return { + id: v.id, + name: v.name, + coverImg: v.coverImgUrl || '', + feedCount: v.feedCount, + }; + }), + }; + } + + async videoPublish( + params: VideoModel, + callback: VideoCallbackType, + ): Promise { + return new Promise(async (resolve) => { + const wxSphParams = params.diffParams![PlatType.WxSph]!; + const result = await shipinhaoService + .publishVideoWorkApi( + params.cookies!, + params.videoPath!, + { + proxy: params.proxyIp || '', + mixInfo: params.mixInfo + ? { + mixId: `${params.mixInfo.value}`, + mixName: params.mixInfo.label, + } + : undefined, + postFlag: wxSphParams.isOriginal ? 1 : 0, + cover: params.coverPath!, + title: params.desc, + topics: params.topics, + des: params.desc, + timingTime: params.timingTime?.getTime(), + // 位置 + ...(params.location + ? { + poiInfo: { + latitude: params.location.latitude, + longitude: params.location.longitude, + poiCity: params.location.city, + poiName: params.location.name, + poiAddress: params.location.simpleAddress, + poiId: params.location.id, + }, + } + : {}), + // @用户 + mentionedUserInfo: params.mentionedUserInfo + ? params.mentionedUserInfo.map((v) => { + return { + nickName: v.label, + }; + }) + : undefined, + // 活动 + event: wxSphParams.activity, + }, + callback, + ) + .catch((e) => { + resolve({ + code: 0, + msg: e, + }); + }); + if (!result || !result.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试', + }); + + return resolve({ + code: 1, + msg: '成功!', + dataId: result.publishId, + }); + }); + } + + async getTopics({}: IGetTopicsParams): Promise { + return Promise.resolve({ + data: [], + status: 400, + }); + } + + async getLocationData(params: IGetLocationDataParams) { + const locationRes = await shipinhaoService.getLocation({ + ...params, + query: params.keywords, + cookie: params.cookie!, + }); + return { + status: this.getCode(locationRes), + data: locationRes?.data?.data?.list?.map((v) => { + return { + name: v.name, + simpleAddress: v.fullAddress, + id: v.uid, + latitude: v.latitude, + longitude: v.longitude, + city: v.city, + }; + }), + }; + } + + /** + * 点赞 + */ + dianzanDyOther(account: AccountModel, pcursor?: string): Promise { + return new Promise((resolve, reject) => {}); + } + + /** + * 收藏 + */ + shoucangDyOther(account: AccountModel, pcursor?: string): Promise { + return new Promise((resolve, reject) => {}); + } + + getsearchNodeList( + account: AccountModel, + pcursor?: string, + ): Promise<{ + list: WorkData[]; + pageInfo: ResponsePageInfo; + }> { + throw '无此方法'; + } +} + +const wxSph = new WxSph(); +export default wxSph; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/xhs/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/xhs/index.ts new file mode 100644 index 000000000..aede85601 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/platforms/xhs/index.ts @@ -0,0 +1,673 @@ +/* + * @Author: nevin + * @Date: 2025-02-08 11:40:45 + * @LastEditTime: 2025-03-24 23:35:16 + * @LastEditors: nevin + * @Description: 小红书 + */ +import { PlatformBase } from '../../PlatformBase'; +import { + AccountInfoTypeRV, + CommentData, + CookiesType, + DashboardData, + IAccountInfoParams, + IGetLocationDataParams, + IGetTopicsParams, + IGetTopicsResponse, + IGetUsersParams, + VideoCallbackType, + WorkData, +} from '../../plat.type'; +import { PublishVideoResult } from '../../module'; +import { + xiaohongshuService, + XSLPlatformSettingType, +} from '../../../../plat/xiaohongshu'; +import { PlatType } from '../../../../../commont/AccountEnum'; +import { AccountModel } from '../../../../db/models/account'; +import { VisibleTypeEnum } from '../../../../../commont/publish/PublishEnum'; +import { CookieToString } from '../../../../plat/utils'; +import { VideoModel } from '../../../../db/models/video'; +import { WorkDataModel } from '../../../../db/models/workData'; +import { ImgTextModel } from '../../../../db/models/imgText'; + +export class Xhs extends PlatformBase { + constructor() { + super(PlatType.Xhs); + } + + /** + * 登录 + * @returns + */ + async login() { + try { + const { success, data, error } = + await xiaohongshuService.loginOrView('login'); + if (!success || !data) { + console.log('Login process failed:', error); + return null; + } + + const userInfo = await xiaohongshuService.getUserInfo(data.cookie); + + const loginCookie = + typeof data.cookie === 'string' + ? data.cookie + : JSON.stringify(data.cookie); + + // 入库 + return { + loginCookie, + type: this.type, + uid: userInfo.authorId, + account: userInfo.authorId, + avatar: userInfo.avatar, + nickname: userInfo.nickname, + fansCount: userInfo.fansCount, + }; + } catch (error) { + console.error('Login process failed:', error); + return null; + } + } + + async loginCheck(account: AccountModel) { + try { + const userInfo = await xiaohongshuService.getUserInfo( + JSON.parse(account.loginCookie), + ); + return { + online: !!userInfo.authorId, + account: { + avatar: userInfo.avatar, + nickname: userInfo.nickname, + fansCount: userInfo.fansCount, + abnormalStatus: { + [PlatType.Xhs]: userInfo.diagnosis_status, + }, + }, + }; + } catch (error) { + console.error(error); + return { + online: false, + }; + } + } + + async getAccountInfo(params: IAccountInfoParams): Promise { + try { + const userInfo = await xiaohongshuService.getUserInfo(params.cookies); + return userInfo; + } catch (error) { + console.log('-----xhs loginCheck-- error', error); + + return null; + } + } + + async getStatistics(account: AccountModel) { + const accountInfo = await xiaohongshuService.getUserInfo( + JSON.parse(account.loginCookie), + ); + return { + fansCount: accountInfo.fansCount, + workCount: 0, // TODO: 作品数量 + }; + return {}; + } + + async getDashboard(account: AccountModel, time: string[] = []) { + const res: DashboardData[] = []; + try { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const ret = await xiaohongshuService.getDashboardFunc( + cookie, + time[0], + time[1], + ); + if (!ret.success) throw new Error('获取三方平台数据失败'); + for (const item of ret.data) { + res.push({ + time: item.date, + fans: item.zhangfen, + read: item.bofang, + comment: item.pinglun, + like: item.dianzan, + forward: item.fenxiang, + collect: 0, // TODO: 获取收藏数据 + }); + } + } catch (error) { + console.log('------ getDashboard wxSph ---', error); + } + + return res; + } + + /** + * 搜索作品列表 + * @param account + * @param pcursor + * @returns + */ + async getsearchNodeList(account: AccountModel, qe?: string, pageInfo?: any) { + // console.log('------ getsearchNodeList xhs pageInfo---', pageInfo); + const pageNo = pageInfo ? Number.parseInt(pageInfo.pcursor) : 0; + // console.log('------ getsearchNodeList xhs pageNo---', pageNo); + const pageSize = 20; + + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await xiaohongshuService.getSearchNodeList( + CookieToString(cookie), + qe || '', + pageNo, + ); + + const list: WorkData[] = res.data.data.items.map((v: any) => ({ + dataId: v.id, + readCount: v.note_card?.interact_info?.view_count, + likeCount: v.note_card?.interact_info?.liked_count, + collectCount: v.note_card?.interact_info?.collected_count, + commentCount: v.note_card?.interact_info?.comment_count, + title: v.note_card?.display_title, + coverUrl: v.note_card?.cover.url_default || '', + option: { + xsec_token: v.xsec_token, + }, + author: { + name: v.note_card?.user?.nickname, + avatar: v.note_card?.user?.avatar, + }, + data: v, + })); + + const count = res.data.data?.tags?.[0]?.notes_count || 0; + const hasMore = res.data.data.has_more; + return { + list, + pageInfo: { + count, + hasMore, + pcursor: hasMore ? pageNo + 1 + '' : '', + }, + }; + } + + /** + * 获取作品列表 + * @param account + * @param pcursor + * @returns + */ + async getWorkList(account: AccountModel, pcursor?: string) { + const pageNo = pcursor ? Number.parseInt(pcursor) : 0; + + const pageSize = 20; + + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await xiaohongshuService.getWorks( + CookieToString(cookie), + pageNo, + ); + + const list: WorkData[] = res.data.data.notes.map((v) => ({ + dataId: v.id, + readCount: v.view_count, + likeCount: v.likes, + collectCount: v.collected_count, + commentCount: v.comments_count, + title: v.display_title, + coverUrl: v.images_list[0]?.url || '', + option: { + xsec_token: v.xsec_token, + }, + })); + + const count = res.data.data?.tags[0]?.notes_count || 0; + const hasMore = count > pageSize * (pageNo + 1); + return { + list, + pageInfo: { + count, + hasMore, + pcursor: hasMore ? pageNo + 1 + '' : '', + }, + }; + } + + /** + * TODO: 未实现 + * @returns + * @param dataId + */ + async getWorkData(dataId: string) { + return { + dataId: '', + }; + } + + async getCommentList( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + + const res = await xiaohongshuService.getCommentList( + cookie, + { + xsec_token: data.option!.xsec_token, + id: data.dataId, + }, + pcursor ? Number.parseInt(pcursor) : undefined, + ); + + // 错误处理 + if (res.status !== 200 || res.data.code) { + console.log('----- getCommentList xhs error---', res); + return { + list: [], + pageInfo: { + count: 0, + hasMore: false, + pcursor: '', + }, + }; + } + + const list: CommentData[] = []; + for (const v of res.data.data.comments) { + const subList: CommentData[] = []; + for (const sub of v.sub_comments) { + subList.push({ + userId: sub.user_info.user_id, + dataId: v.note_id, + commentId: sub.id, + parentCommentId: v.id, + content: sub.content, + likeCount: Number.parseInt(sub.like_count), + nikeName: sub.user_info.nickname, + headUrl: sub.user_info.image, + subCommentList: [], + }); + } + + list.push({ + userId: v.user_info.user_id, + dataId: v.note_id, + commentId: v.id, + parentCommentId: undefined, + content: v.content, + likeCount: Number.parseInt(v.like_count), + nikeName: v.user_info.nickname, + headUrl: v.user_info.image, + data: v, + subCommentList: subList, + }); + } + + return { + list: list, + pageInfo: { + hasMore: res.data.data.has_more, + pcursor: res.data.data.cursor, + }, + }; + } + + async getCreatorCommentListByOther( + account: AccountModel, + data: WorkData, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await xiaohongshuService.getCommentList(cookie, { + xsec_token: data.option!.xsec_token, + id: data.dataId, + }); + + const list: CommentData[] = []; + + for (const v of res.data.data.comments) { + const subList: CommentData[] = []; + + for (const sub of v.sub_comments) { + subList.push({ + userId: sub.user_info.user_id, + dataId: v.note_id, + commentId: sub.id, + parentCommentId: v.id, + content: sub.content, + likeCount: Number.parseInt(sub.like_count), + nikeName: sub.user_info.nickname, + headUrl: sub.user_info.image, + subCommentList: [], + }); + } + + list.push({ + userId: v.user_info.user_id, + dataId: v.note_id, + commentId: v.id, + parentCommentId: undefined, + content: v.content, + likeCount: Number.parseInt(v.like_count), + nikeName: v.user_info.nickname, + headUrl: v.user_info.image, + data: v, + subCommentList: subList, + }); + } + + return { + list: list, + pageInfo: { + hasMore: res.data.data.has_more, + pcursor: res.data.data.cursor, + }, + }; + } + + async getCreatorSecondCommentListByOther( + account: AccountModel, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + + const res = await xiaohongshuService.getSecondCommentList( + cookie, + data.dataId, + root_comment_id, + pcursor, + ); + console.log('------ getCreatorSecondCommentListByOther xhs res---', res); + + const list: CommentData[] = []; + + for (const v of res.data.data.comments) { + list.push({ + userId: v.user_info.user_id, + dataId: v.note_id, + commentId: v.id, + parentCommentId: undefined, + content: v.content, + likeCount: Number.parseInt(v.like_count), + nikeName: v.user_info.nickname, + headUrl: v.user_info.image, + data: v, + subCommentList: [], + }); + } + + return { + list: list, + pageInfo: { + hasMore: res.data.data.has_more, + pcursor: res.data.data.cursor, + }, + }; + } + + async createCommentByOther( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const ret = await xiaohongshuService.commentPost(cookie, dataId, content); + // console.log('------ createCommentByOther xhs ---', ret); + return ret; + } + + async dianzanDyOther( + account: AccountModel, + dataId: string, // 作品ID + ): Promise { + console.log('------ dianzanDyOther3333', dataId); + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await xiaohongshuService.likeNote(cookie, dataId); + + console.log('------ res 小红书点赞...: ', res); + + return res; + } + + async shoucangDyOther( + account: AccountModel, + dataId: string, // 作品ID + ): Promise { + console.log('------ shoucangDyOther5555', dataId); + const cookie: CookiesType = JSON.parse(account.loginCookie); + const res = await xiaohongshuService.shoucangNote(cookie, dataId); + + // console.log('------ res', res); + + return res; + } + + async replyCommentByOther( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const ret = await xiaohongshuService.commentPost( + cookie, + option.dataId!, + content, + commentId, + ); + + return ret; + } + + async createComment( + account: AccountModel, + dataId: string, // 作品ID + content: string, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const ret = await xiaohongshuService.commentPost(cookie, dataId, content); + + return false; + } + + async replyComment( + account: AccountModel, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ) { + const cookie: CookiesType = JSON.parse(account.loginCookie); + const ret = await xiaohongshuService.commentPost( + cookie, + option.dataId!, + content, + commentId, + ); + + return false; + } + /** + * @param params + * @param callback + * @returns + */ + async videoPublish( + params: VideoModel, + callback: VideoCallbackType, + ): Promise { + return new Promise(async (resolve) => { + const result = await xiaohongshuService + .publishVideoWorkApi( + JSON.stringify(params.cookies), + params.videoPath, + this.pubParamsParse(params), + callback, + ) + .catch((err) => { + resolve({ + code: 0, + msg: err, + }); + }); + + if (!result || !result.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试!', + }); + + return resolve({ + code: 1, + msg: '成功!', + dataId: result!.publishId, + previewVideoLink: result.shareLink, + }); + }); + } + + async getUsers(params: IGetUsersParams) { + const usersRes = await xiaohongshuService.getUsers( + JSON.parse(params.account.loginCookie), + params.keyword, + params.page, + ); + return { + status: usersRes.status, + data: usersRes?.data?.data?.user_info_dtos?.map((v) => { + return { + image: v.user_base_dto.image, + id: v.user_base_dto.red_id, + name: v.user_base_dto.user_nickname, + }; + }), + }; + } + + async getTopics({ + keyword, + account, + }: IGetTopicsParams): Promise { + const res = await xiaohongshuService.getTopics({ + keyword, + cookies: JSON.parse(account.loginCookie), + }); + return { + status: res.status, + data: res?.data?.data?.topic_info_dtos?.map((v) => { + return { + id: v.id, + name: v.name, + view_count: v.view_num, + }; + }), + }; + } + + async getLocationData(params: IGetLocationDataParams) { + const locationRes = await xiaohongshuService.getLocations({ + ...params, + keyword: params.keywords, + cookies: params.cookie!, + }); + return { + status: locationRes.status, + data: locationRes?.data?.data?.poi_list?.map((v) => { + return { + name: v.name, + simpleAddress: v.full_address, + id: v.poi_id, + poi_type: v.poi_type, + latitude: v.latitude, + longitude: v.longitude, + city: v.city_name, + }; + }), + }; + } + + pubParamsParse(params: WorkDataModel): XSLPlatformSettingType { + return { + proxy: params.proxyIp || '', + cover: params.coverPath || '', + desc: params.desc, + title: params.title, + topicsDetail: + params.topics?.map((v) => ({ + topicId: v, + topicName: v, + })) || [], + timingTime: params.timingTime?.getTime(), + visibility_type: + params.visibleType === VisibleTypeEnum.Public + ? 0 + : params.visibleType === VisibleTypeEnum.Private + ? 1 + : 4, + // 位置 + poiInfo: params.location + ? { + poiType: params.location.poi_type!, + poiId: params.location.id, + poiName: params.location.name, + poiAddress: params.location.simpleAddress, + } + : undefined, + // @用户 + mentionedUserInfo: params.mentionedUserInfo + ? params.mentionedUserInfo.map((v) => { + return { + nickName: v.label, + uid: `${v.value}`, + }; + }) + : undefined, + }; + } + + async imgTextPublish(params: ImgTextModel): Promise { + return new Promise(async (resolve) => { + const result = await xiaohongshuService + .publishImageWorkApi( + JSON.stringify(params.cookies), + params.imagesPath, + this.pubParamsParse(params), + ) + .catch((err) => { + resolve({ + code: 0, + msg: err, + }); + }); + + if (!result || !result.publishId) + return resolve({ + code: 0, + msg: '网络繁忙,请稍后重试!', + }); + + return resolve({ + code: 1, + msg: '成功!', + dataId: result!.publishId, + previewVideoLink: result.shareLink, + }); + }); + } +} + +const xhs = new Xhs(); +export default xhs; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemBase.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemBase.ts new file mode 100644 index 000000000..81aad35c7 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemBase.ts @@ -0,0 +1,12 @@ +import { AccountModel } from '../../../db/models/account'; +import { PlatformBase } from '../PlatformBase'; + +export abstract class PubItemBase { + accountModel: AccountModel; + platform: PlatformBase; + + protected constructor(accountModel: AccountModel, platform: PlatformBase) { + this.accountModel = accountModel; + this.platform = platform; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemImgText.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemImgText.ts new file mode 100644 index 000000000..5fc78fec3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemImgText.ts @@ -0,0 +1,74 @@ +/* + * @Author: nevin + * @Date: 2025-02-07 20:00:47 + * @LastEditTime: 2025-04-24 14:30:42 + * @LastEditors: nevin + * @Description: + */ +import { AccountModel } from '../../../db/models/account'; +import { PubItemBase } from './PubItemBase'; +import { PlatformBase } from '../PlatformBase'; +import { ImgTextModel } from '../../../db/models/imgText'; +import { EtEvent } from '../../../global/event'; +import { PublishProgressRes } from './PubItemVideo'; +import windowOperate from '../../../util/windowOperate'; +import { SendChannelEnum } from '../../../../commont/UtilsEnum'; +import { PubStatus } from '../../../../commont/publish/PublishEnum'; + +/** + * 视频发布单条处理逻辑 + */ +export class PubItemImgText extends PubItemBase { + imgTextModel: ImgTextModel; + + constructor( + accountModel: AccountModel, + imgTextModel: ImgTextModel, + platform: PlatformBase, + ) { + super(accountModel, platform); + this.imgTextModel = imgTextModel; + } + + async publishImgText() { + const params: ImgTextModel = { + ...this.imgTextModel, + cookies: JSON.parse(this.accountModel.loginCookie), + token: this.accountModel.token, + }; + console.log('图文发布原始参数:', params); + const publishVideoResult = await this.platform.imgTextPublish(params); + // 发布失败 + if (publishVideoResult.code === 0) { + this.imgTextModel.status = PubStatus.FAIL; + this.imgTextModel.failMsg = publishVideoResult.msg.toString(); + } else { + // 发布成功 + this.imgTextModel.status = PubStatus.RELEASED; + this.imgTextModel.dataId = publishVideoResult.dataId; + this.imgTextModel.previewVideoLink = publishVideoResult.previewVideoLink; + } + // 发布进度 + const progressRes: PublishProgressRes = { + id: 1, + progress: publishVideoResult.code === 0 ? -1 : 100, + msg: '', + account: this.accountModel, + }; + // 图文发布进度,向渲染层发送进度 + windowOperate.sendRenderMsg( + SendChannelEnum.ImgTextPublishProgress, + progressRes, + ); + await this.uploadRecord(); + return publishVideoResult; + } + + /** + * 更新图文记录 + */ + async uploadRecord() { + this.imgTextModel.proxyIp = undefined; + EtEvent.emit('ET_PUBLISH_UPDATE_IMG_TEXT_PUL', this.imgTextModel); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemVideo.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemVideo.ts new file mode 100644 index 000000000..43660d11f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/plat/pub/PubItemVideo.ts @@ -0,0 +1,99 @@ +/* + * @Author: nevin + * @Date: 2025-02-07 20:00:47 + * @LastEditTime: 2025-03-19 14:27:53 + * @LastEditors: nevin + * @Description: + */ +import { VideoModel } from '../../../db/models/video'; +import { AccountModel } from '../../../db/models/account'; +import { PubItemBase } from './PubItemBase'; +import { PlatformBase } from '../PlatformBase'; +import { EtEvent } from '../../../global/event'; +import windowOperate from '../../../util/windowOperate'; +import { SendChannelEnum } from '../../../../commont/UtilsEnum'; +import { PubStatus } from '../../../../commont/publish/PublishEnum'; + +// 视频发布进度返回值 +export interface PublishProgressRes { + // 为 -1 表示失败 + progress: number; + msg: string; + account: AccountModel; + id: number; +} + +/** + * 视频发布单条处理逻辑 + */ +export class PubItemVideo extends PubItemBase { + videoModel: VideoModel; + + constructor( + accountModel: AccountModel, + videoModel: VideoModel, + platform: PlatformBase, + ) { + super(accountModel, platform); + this.videoModel = videoModel; + } + + /** + * 发布视频 + */ + async publishVideo() { + const publishVideoResult = await this.platform.videoPublish( + { + ...this.videoModel, + cookies: JSON.parse(this.accountModel.loginCookie), + token: this.accountModel.token, + }, + (progress: number, msg?: string) => { + const args: PublishProgressRes = { + progress, + msg: msg || '', + account: this.accountModel, + id: this.videoModel.pubRecordId!, + }; + // 视频发布进度,向渲染层发送进度 + windowOperate.sendRenderMsg(SendChannelEnum.VideoPublishProgress, args); + }, + ); + // 发布失败 + if (publishVideoResult.code === 0) { + this.videoModel.status = PubStatus.FAIL; + this.videoModel.failMsg = publishVideoResult.msg.toString(); + windowOperate.sendRenderMsg(SendChannelEnum.VideoPublishProgress, { + progress: -1, + msg: '发布失败!', + account: this.accountModel, + id: this.videoModel.pubRecordId, + }); + } else { + // 发布成功 + if (typeof publishVideoResult.pubStatus === 'number') { + this.videoModel.status = publishVideoResult.pubStatus; + } else { + this.videoModel.status = PubStatus.RELEASED; + } + this.videoModel.dataId = publishVideoResult.dataId; + this.videoModel.previewVideoLink = publishVideoResult.previewVideoLink; + windowOperate.sendRenderMsg(SendChannelEnum.VideoPublishProgress, { + id: this.videoModel.pubRecordId, + progress: 100, + msg: '发布成功!', + account: this.accountModel, + }); + } + await this.uploadRecord(); + return publishVideoResult; + } + + /** + * 更新视频记录 + */ + async uploadRecord() { + this.videoModel.proxyIp = undefined; + EtEvent.emit('ET_PUBLISH_UPDATE_VIDEO_PUL', this.videoModel); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/controller.ts new file mode 100644 index 000000000..35c5eec39 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/controller.ts @@ -0,0 +1,244 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-02-22 20:39:02 + * @LastEditors: nevin + * @Description: + */ +import { Controller, Icp, Inject } from '../core/decorators'; +import { PublishService } from './service'; +import { getUserInfo } from '../user/comment'; +import { Between, FindOptionsWhere } from 'typeorm'; +import { + backPageData, + type CorrectQuery, + type pubRecordListQuery, +} from '../../global/table'; +import { PubRecordModel } from '../../db/models/pubRecord'; +import { PubStatus, PubType } from '../../../commont/publish/PublishEnum'; +import { VideoPubService } from './video/service'; +import { VideoModel } from '../../db/models/video'; +import platController from '../plat'; +import { AccountModel } from '../../db/models/account'; +import type { + IGetLocationDataParams, + IGetUsersParams, +} from '../plat/plat.type'; +import { douyinService } from '../../plat/douyin'; +import { shipinhaoService } from '../../plat/shipinhao'; + +@Controller() +export class PublishController { + @Inject(PublishService) + private readonly publishService!: PublishService; + + @Inject(VideoPubService) + private readonly videoPubService!: VideoPubService; + + // 创建发布记录 + @Icp('ICP_PUBLISH_CREATE_PUB_RECORD') + async createPubRecord( + event: Electron.IpcMainInvokeEvent, + pubRecord: PubRecordModel, + ): Promise { + pubRecord.userId = getUserInfo().id; + pubRecord.publishTime = new Date(); + return await this.publishService.createPubRecord(pubRecord); + } + + // 获取发布记录列表 + @Icp('ICP_PUBLISH_GET_PUB_RECORD_LIST') + async getPubRecordList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + query?: pubRecordListQuery, + ): Promise { + const userInfo = getUserInfo(); + const filters: FindOptionsWhere = !query + ? {} + : { + ...(query.type !== undefined && { type: query.type }), + ...(query.status === undefined + ? {} + : { + status: query.status, + }), + ...(query.time !== undefined && + query.time.length === 2 && + Between(new Date(query.time[0]), new Date(query.time[1]))), + }; + return await this.publishService.getPubRecordList( + userInfo.id, + page, + filters, + ); + } + + // 获取草稿列表 + @Icp('ICP_PUBLISH_GET_PUB_RECORD_DRAFTS_LIST') + async getPubRecordDraftsList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + query?: { + time?: [string, string]; + type?: PubType; + }, + ): Promise { + const userInfo = getUserInfo(); + const filters: FindOptionsWhere = !query + ? { + status: PubStatus.UNPUBLISH, + } + : { + status: PubStatus.UNPUBLISH, + ...(query.type !== undefined && { type: query.type }), + ...(query.time !== undefined && + query.time.length === 2 && + Between(new Date(query.time[0]), new Date(query.time[1]))), + }; + return await this.publishService.getPubRecordList( + userInfo.id, + page, + filters, + ); + } + + // 获取发布记录的发布内容列表 + @Icp('ICP_PUBLISH_GET_PUB_RECORD_ITEM_LIST') + async getPubRecordItemList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + id: number, + ): Promise { + let res = backPageData([], 0, page); + const pubRecordInfo = await this.publishService.getPubRecordInfo(id); + if (!pubRecordInfo) return res; + + if (pubRecordInfo.type === PubType.VIDEO) { + res = await this.videoPubService.getVideoPulListByPubRecordIdToShow( + id, + page, + ); + } else if (pubRecordInfo.type === PubType.ARTICLE) { + // TODO: 图文 + return res; + } + + return res; + } + + // 获取发布记录信息 + @Icp('ICP_PUBLISH_GET_PUB_RECORD_INFO') + async getPubRecordInfo( + event: Electron.IpcMainInvokeEvent, + id: number, + ): Promise { + return await this.publishService.getPubRecordInfo(id); + } + + // 删除发布记录 + @Icp('ICP_PUBLISH_DEL_PUB_RECORD_BY_ID') + async delPubRecord(event: Electron.IpcMainInvokeEvent, id: number) { + return await this.publishService.deletePubRecordById(id); + } + + // 获取所有平台话题 + @Icp('ICP_PUBLISH_GET_TOPIC') + async getTopic( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + keyword: string, + ) { + return await platController.getTopic(account, keyword); + } + + // 获取所有平台位置数据 + @Icp('ICP_PUBLISH_GET_LOCATION') + async getLocationData( + event: Electron.IpcMainInvokeEvent, + params: IGetLocationDataParams, + ) { + return await platController.getLocationData(params); + } + + // 获取所有平台合集数据 + @Icp('ICP_PUBLISH_GET_MIX_LIST') + async getMixList(event: Electron.IpcMainInvokeEvent, account: AccountModel) { + return await platController.getMixList(account); + } + + // 获取所有平台的用户数据 + @Icp('ICP_PUBLISH_GET_USERS') + async getUsers(event: Electron.IpcMainInvokeEvent, params: IGetUsersParams) { + return await platController.getUsers(params); + } + + // 获取抖音热点数据 + @Icp('ICP_PUBLISH_GET_DOYTIN_HOT') + async getDoytinHot( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + query: string, + ) { + const res = await douyinService.getHotspotData({ + query: query, + cookie: JSON.parse(account.loginCookie), + }); + return res.data; + } + + // 获取抖音所有热点数据 + @Icp('ICP_PUBLISH_GET_ALL_DOYTIN_HOT') + async getDoytinHotAll(event: Electron.IpcMainInvokeEvent) { + const res = await douyinService.getAllHotspotData(); + return res.data; + } + + // 获取抖音的活动列表 + @Icp('ICP_PUBLISH_GET_DOUYIN_ACTIVITY') + async getDouyinActivity( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + ) { + const res = await douyinService.getActivity( + JSON.parse(account.loginCookie), + ); + return res.data; + } + + // 获取抖音的活动详情 + @Icp('ICP_PUBLISH_GET_DOUYIN_ACTIVITY_DETAILS') + async getDouyinActivityDetails( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + activity_id: string, + ) { + const res = await douyinService.getActivityDetails( + JSON.parse(account.loginCookie), + activity_id, + ); + return res.data; + } + + // 获取抖音活动标签 + @Icp('ICP_PUBLISH_GET_DOUYIN_ACTIVITY_TAGS') + async getActivityTags( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + ) { + return await douyinService.getActivityTags(JSON.parse(account.loginCookie)); + } + + // 获取微信视频号的活动 + @Icp('ICP_PUBLISH_GET_WXSPH_ACTIVITY') + async getSphActivity( + event: Electron.IpcMainInvokeEvent, + account: AccountModel, + query: string, + ) { + return await shipinhaoService.getActivityList({ + cookie: JSON.parse(account.loginCookie), + query, + }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/controller.ts new file mode 100644 index 000000000..d0b2db058 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/controller.ts @@ -0,0 +1,95 @@ +import { Controller, Et, Icp, Inject } from '../../core/decorators'; +import { PublishService } from '../service'; +import { AccountService } from '../../account/service'; +import { ImgTextPubService } from './service'; +import { ImgTextModel } from '../../../db/models/imgText'; +import platController from '../../plat'; +import { PubStatus } from '../../../../commont/publish/PublishEnum'; + +@Controller() +export class ImgTextPubController { + @Inject(ImgTextPubService) + private readonly imgTextService!: ImgTextPubService; + + @Inject(PublishService) + private readonly publishService!: PublishService; + + @Inject(AccountService) + private readonly accountService!: AccountService; + + // 创建图文发布记录 + @Icp('ICP_PUBLISH_CREATE_IMG_TEXT_PUL') + async createVideoPub( + event: Electron.IpcMainInvokeEvent, + imgText: ImgTextModel, + ): Promise { + return await this.imgTextService.createImgTextPul(imgText); + } + + // 更新视频发布数据 + @Et('ET_PUBLISH_UPDATE_IMG_TEXT_PUL') + async updateImgTextPul(imgTextModel: ImgTextModel): Promise { + await this.imgTextService.updateImgTextPul(imgTextModel); + } + + // 根据发布记录ID获取图文发布列表 + @Icp('ICP_PUBLISH_GET_IMG_TEXT_LIST') + async getImgTextList( + event: Electron.IpcMainInvokeEvent, + pubRecordId: number, + ) { + return await this.imgTextService.getImgTextPulListByPubRecordId( + pubRecordId, + ); + } + + // 发布图文 + @Icp('ICP_PUBLISH_IMG_TEXT') + async pubImgText(event: Electron.IpcMainInvokeEvent, pubRecordId: number) { + const pubRecordInfo = + await this.publishService.getPubRecordInfo(pubRecordId); + + if (pubRecordInfo?.status === PubStatus.RELEASED) { + console.error('发布记录已发布'); + } + + // 获取图文发布记录列表 + const imgTextModels = + await this.imgTextService.getImgTextPulListByPubRecordId(pubRecordId); + // 获取用到的账户信息 + const accountList = await this.accountService.getAccountsByIds( + imgTextModels.map((v) => v.accountId), + ); + + // 获取代理IP + const groupModels = await this.accountService.getAccountGroup(); + for (let i = 0; i < accountList.length; i++) { + const account = accountList[i]; + const group = groupModels.find((v) => v.id === account.groupId); + if (group && group.proxyIp && group.proxyOpen) { + imgTextModels[i].proxyIp = group.proxyIp; + } + } + + // 发布 + const pubRes = await platController.imgTextPublish( + imgTextModels, + accountList, + ); + + let successCount = 0; + pubRes.map((v) => { + if (v.code === 1) successCount++; + }); + // 更改记录状态 + await this.publishService.updatePubRecordStatus( + pubRecordId, + successCount === 0 + ? PubStatus.FAIL + : successCount === pubRes.length + ? PubStatus.RELEASED + : PubStatus.PartSuccess, + ); + return pubRes; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/service.ts new file mode 100644 index 000000000..72dfaa77d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/imgText/service.ts @@ -0,0 +1,32 @@ +import { Repository } from 'typeorm'; +import { ImgTextModel } from '../../../db/models/imgText'; +import { AppDataSource } from '../../../db'; +import { Injectable } from '../../core/decorators'; +import { getUserInfo } from '../../user/comment'; + +@Injectable() +export class ImgTextPubService { + private imgTextRepository: Repository; + + constructor() { + this.imgTextRepository = AppDataSource.getRepository(ImgTextModel); + } + + // 创建图文记录 + async createImgTextPul(imgText: ImgTextModel) { + imgText.userId = getUserInfo().id; + return await this.imgTextRepository.save(imgText); + } + + // 更新数据 + async updateImgTextPul(imgTextModel: ImgTextModel) { + return await this.imgTextRepository.update(imgTextModel.id!, imgTextModel); + } + + // 根据发布记录ID获取图文记录列表 + async getImgTextPulListByPubRecordId(pubRecordId: number) { + return await this.imgTextRepository.find({ + where: { pubRecordId }, + }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/module.ts new file mode 100644 index 000000000..38f8f7ff8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/module.ts @@ -0,0 +1,20 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-02-06 19:13:48 + * @LastEditors: nevin + * @Description: 发布相关模块 + */ +import { Module } from '../core/decorators'; +import { PublishController } from './controller'; +import { PublishService } from './service'; +import { VideoPubController } from './video/controller'; +import { VideoPubService } from './video/service'; +import { ImgTextPubController } from './imgText/controller'; +import { ImgTextPubService } from './imgText/service'; + +@Module({ + controllers: [PublishController, VideoPubController, ImgTextPubController], + providers: [PublishService, VideoPubService, ImgTextPubService], +}) +export class PublishModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/service.ts new file mode 100644 index 000000000..184405b4f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/service.ts @@ -0,0 +1,68 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 发布 + */ +import { AppDataSource } from '../../db'; +import { Injectable } from '../core/decorators'; +import { FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; +import { CorrectQuery, backPageData } from '../../global/table'; +import { PubRecordModel } from '../../db/models/pubRecord'; +import { EtEvent } from '../../global/event'; +import { getUserInfo } from '../user/comment'; +import { PubStatus } from '../../../commont/publish/PublishEnum'; +@Injectable() +export class PublishService { + private pubRecordRepository: Repository; + constructor() { + this.pubRecordRepository = AppDataSource.getRepository(PubRecordModel); + console.log('PublishService constructor'); + } + + // 创建发布记录 + async createPubRecord(pubRecord: PubRecordModel) { + return await this.pubRecordRepository.save(pubRecord); + } + + // 获取发布记录列表 + async getPubRecordList( + userId: string, + page: CorrectQuery, + query?: FindOptionsWhere, + ) { + const file: FindManyOptions = { + where: { userId: userId, ...query }, + order: { publishTime: 'DESC' }, + skip: (page.page_no - 1) * page.page_size, + }; + const [list, totalCount] = + await this.pubRecordRepository.findAndCount(file); + return backPageData(list, totalCount, page); + } + + // 获取发布记录信息 + async getPubRecordInfo(id: number) { + const userInfo = getUserInfo(); + const pubRecordInfo = await this.pubRecordRepository.findOne({ + where: { id }, + }); + if (!pubRecordInfo || pubRecordInfo.userId !== userInfo.id) { + console.error('发布记录不存在'); + } + return pubRecordInfo; + } + + // 更新发布记录的状态 + async updatePubRecordStatus(id: number, status: PubStatus) { + return await this.pubRecordRepository.update(id, { status }); + } + + // 删除发布记录 + async deletePubRecordById(id: number): Promise { + const { affected } = await this.pubRecordRepository.delete(id); + const res = affected ? true : false; + if (res) EtEvent.emit('ET_DEL_PUB_RECORD_ITEM', id); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/comment.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/controller.ts new file mode 100644 index 000000000..9aac32e59 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/controller.ts @@ -0,0 +1,219 @@ +/* + * @Author: nevin + * @Date: 2025-02-06 17:09:35 + * @LastEditTime: 2025-02-14 18:31:12 + * @LastEditors: nevin + * @Description: + */ + +import { dialog } from 'electron'; +import { Controller, Et, Icp, Inject } from '../../core/decorators'; +import { VideoPubService } from './service'; +import { getUserInfo } from '../../user/comment'; +import { VideoModel } from '../../../db/models/video'; +import { Between, FindOptionsWhere } from 'typeorm'; +import type { CorrectQuery } from '../../../global/table'; +import { PublishService } from '../service'; +import platController from '../../plat'; +import { AccountService } from '../../account/service'; +import { PlatType } from '../../../../commont/AccountEnum'; +import { PubStatus } from '../../../../commont/publish/PublishEnum'; + +@Controller() +export class VideoPubController { + @Inject(VideoPubService) + private readonly videoPubService!: VideoPubService; + + @Inject(PublishService) + private readonly publishService!: PublishService; + + @Inject(AccountService) + private readonly accountService!: AccountService; + + // 上传视频 + @Icp('ICP_ACCOUNT_UPLOAD_VIDEO') + async uploadVideo(event: Electron.IpcMainInvokeEvent): Promise { + // 打开文件选择对话框 + const result = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: '所有文件', extensions: ['*'] }], + }); + + if (result.filePaths.length === 0) return; + return result.filePaths[0]; + } + + // 发布视频记录获取 + @Icp('ICP_PUB_GET_VIDEO_RECORD') + async getVideoRecord( + event: Electron.IpcMainInvokeEvent, + pubRecordId: number, + ) { + return this.videoPubService.getVideoPulListByPubRecordId(pubRecordId); + } + + // 发布视频 + @Icp('ICP_PUB_VIDEO') + async pubVideo(event: Electron.IpcMainInvokeEvent, pubRecordId: number) { + try { + const pubRecordInfo = + await this.publishService.getPubRecordInfo(pubRecordId); + + if (pubRecordInfo?.status === PubStatus.RELEASED) { + console.error('发布记录已发布'); + } + + // 获取视频发布记录列表 + const videoList = + await this.videoPubService.getVideoPulListByPubRecordId(pubRecordId); + // 获取用到的账户信息 + const accountList = await this.accountService.getAccountsByIds( + videoList.map((v) => v.accountId), + ); + + // 获取代理IP + const groupModels = await this.accountService.getAccountGroup(); + for (let i = 0; i < accountList.length; i++) { + const account = accountList[i]; + const group = groupModels.find((v) => v.id === account.groupId); + if (group && group.proxyIp && group.proxyOpen) { + videoList[i].proxyIp = group.proxyIp; + } + } + + // 发布 + const pubRes = await platController.videoPublish(videoList, accountList); + + let successCount = 0; + pubRes.map((v) => { + if (v.code === 1) { + successCount++; + } + }); + + // 更改记录状态 + const theStatus = + successCount === 0 + ? PubStatus.FAIL + : successCount === pubRes.length + ? PubStatus.RELEASED + : PubStatus.PartSuccess; + + await this.publishService.updatePubRecordStatus(pubRecordId, theStatus); + + return pubRes; + } catch (e) { + console.error(e); + } + } + + /** + * 获取视频发布列表 + */ + @Icp('ICP_PUBLISH_GET_VIDEO_PUL_LIST') + async getVideoPulList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + query: { + pubRecardId?: number; + type?: PlatType; + time?: [string, string]; + title?: string; + }, + ): Promise { + const userInfo = getUserInfo(); + const where: FindOptionsWhere = { + userId: userInfo.id, + ...(query.pubRecardId && { pubRecordId: query.pubRecardId }), + ...(query.title && { title: query.title }), + ...(query.time && + query.time.length === 2 && + Between(new Date(query.time[0]), new Date(query.time[1]))), + }; + + return await this.videoPubService.getVideoPulList(userInfo.id, page, where); + } + + /** + * 获取视频发布信息 + */ + @Icp('ICP_PUBLISH_GET_VIDEO_PUL_INFO') + async getVideoPulInfo( + event: Electron.IpcMainInvokeEvent, + id: number, + ): Promise { + return await this.videoPubService.getVideoPulInfo(id); + } + + /** + * 创建视频发布记录 + */ + @Icp('ICP_PUBLISH_CREATE_VIDEO_PUL') + async createVideoPub( + event: Electron.IpcMainInvokeEvent, + video: VideoModel, + ): Promise { + return await this.videoPubService.newVideoPul(video); + } + + /** + * 获取不同类型的视频发布的总数 + */ + @Icp('ICP_PUBLISH_GET_VIDEO_PUL_TYPE_COUNT') + async getVideoPulTypeCount( + event: Electron.IpcMainInvokeEvent, + type?: PlatType, + ): Promise { + const userInfo = getUserInfo(); + return await this.videoPubService.getVideoPulTypeCount(userInfo.id, type); + } + + // 获取状态为审核中的视频列表 + @Et('ET_PUBLISH_VIDEO_AUDIT_LIST') + async getAuditVideoList(callback: (_: VideoModel[]) => void): Promise { + console.log(callback); + const userInfo = getUserInfo(); + if (!userInfo?.id) return []; + const res = await this.videoPubService.getVideoPulList( + userInfo.id, + { + page_size: 20, + page_no: 1, + }, + { + status: PubStatus.Audit, + }, + ); + callback(res.list); + } + + // 根据dataid更新数据 + @Et('ET_PUBLISH_UPDATE_VIDEO_PUL_BY_DATAID') + async updateVideoPubByDataid(videoModel: Partial): Promise { + return await this.videoPubService.updateVideoPulByDataId(videoModel); + } + + // 更新视频发布数据 + @Et('ET_PUBLISH_UPDATE_VIDEO_PUL') + async updateVideoPul(videoModel: VideoModel): Promise { + await this.videoPubService.updateVideoPul(videoModel); + } + + @Et('ET_DEL_PUB_RECORD_ITEM') + async delVideoPul(pulRecordId: number) { + await this.videoPubService.deleteVideoPulByPubRecordId(pulRecordId); + } + + // 删除发布记录 + @Icp('ICP_PUBLISH_DEL_PUB_RECORD_ITEM_VIDEO') + async delPubRecordItem( + event: Electron.IpcMainInvokeEvent, + id: number, + accountId: number, + ) { + return await this.videoPubService.deleteByPubRecordAndAccount( + id, + accountId, + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/service.ts new file mode 100644 index 000000000..1135c6cea --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/publish/video/service.ts @@ -0,0 +1,112 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 视频发布服务 + */ +import { VideoModel } from '../../../db/models/video'; +import { AppDataSource } from '../../../db'; +import { Injectable } from '../../core/decorators'; +import { FindManyOptions, FindOptionsWhere, Repository } from 'typeorm'; +import { CorrectQuery, backPageData } from '../../../global/table'; +import { getUserInfo } from '../../user/comment'; +import { PlatType } from '../../../../commont/AccountEnum'; + +@Injectable() +export class VideoPubService { + private videoRepository: Repository; + constructor() { + this.videoRepository = AppDataSource.getRepository(VideoModel); + console.log('VideoPubService constructor'); + } + + // 创建视频发布数据 + async newVideoPul(video: VideoModel) { + video.userId = getUserInfo().id; + return await this.videoRepository.save(video); + } + + // 更新视频数据 + async updateVideoPul(videoModel: VideoModel) { + return await this.videoRepository.update(videoModel.id!, videoModel); + } + + // 根据dataid更新数据 + async updateVideoPulByDataId(videoModel: Partial) { + return await this.videoRepository.update( + { dataId: videoModel.dataId }, + videoModel, + ); + } + + // 获取视频发布信息 + async getVideoPulInfo(id: number) { + return await this.videoRepository.findOne({ where: { id } }); + } + + // 获取发布记录相关的视频发布列表 + async getVideoPulListByPubRecordId(pubRecordId: number) { + const file: FindManyOptions = { + where: { pubRecordId: pubRecordId }, + }; + return await this.videoRepository.find(file); + } + + // 获取发布记录相关的视频发布列表 + async getVideoPulListByPubRecordIdToShow( + pubRecordId: number, + page: CorrectQuery, + ) { + const file: FindManyOptions = { + where: { pubRecordId: pubRecordId }, + order: { publishTime: 'DESC' }, + skip: (page.page_no - 1) * page.page_size, + take: page.page_size, + }; + const [list, totalCount] = await this.videoRepository.findAndCount(file); + return backPageData(list, totalCount, page); + } + + // 视频发布列表 + async getVideoPulList( + userId: string, + page: CorrectQuery, + query?: FindOptionsWhere, + ) { + const file: FindManyOptions = { + where: { userId: userId, ...query }, + order: { publishTime: 'DESC' }, + skip: (page.page_no - 1) * page.page_size, + take: page.page_size, + }; + const [list, totalCount] = await this.videoRepository.findAndCount(file); + return backPageData(list, totalCount, page); + } + + // 删除视频发布 + async deleteVideoPul(id: number) { + return await this.videoRepository.delete(id); + } + + // 获取不同类型的视频发布的总数 + async getVideoPulTypeCount(userId: string, type?: PlatType) { + return await this.videoRepository.count({ + where: { userId: userId, type: type }, + }); + } + + // 删除 + async deleteVideoPulByPubRecordId(pubRecordId: number): Promise { + const res = await this.videoRepository.delete({ pubRecordId }); + return res.affected ? true : false; + } + + // 删除 + async deleteByPubRecordAndAccount( + pubRecordId: number, + accountId: number, + ): Promise { + const res = await this.videoRepository.delete({ pubRecordId, accountId }); + return res.affected ? true : false; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/cacheData.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/cacheData.ts new file mode 100644 index 000000000..044c62bcd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/cacheData.ts @@ -0,0 +1,64 @@ +import { GlobleCache } from '../../global/cache'; + +// 0 进行中 1 完成 2 错误 +export enum AutorReplyCacheStatus { + DOING = 0, + DONE = 1, + REEOR = 2, +} + +export type AutoReplyCacheData = { + status: AutorReplyCacheStatus; + message: string; + createTime?: number; + updateTime?: number; + title: string; + dataId?: string; +}; + +export class AutoReplyCache { + static cacheKey = 'OneKeyReplyCommentCacheKey'; + constructor(data: { title: string; dataId?: string }) { + const cacheData = { + status: AutorReplyCacheStatus.DOING, + message: '进行中', + updateTime: new Date().getTime(), + createTime: + (GlobleCache.getCache(AutoReplyCache.cacheKey) as AutoReplyCacheData) + ?.createTime || new Date().getTime(), + ...data, + }; + + GlobleCache.setCache(AutoReplyCache.cacheKey, cacheData, 60 * 15); // 设置缓存 + } + + // 获取信息 + static getInfo() { + return GlobleCache.getCache( + AutoReplyCache.cacheKey, + ) as AutoReplyCacheData | null; + } + + // 延长ttl + extendTTL() { + GlobleCache.updateCacheTTL(AutoReplyCache.cacheKey, 60 * 15); // 重设缓存时间 + } + + // 更新状态 + updateStatus(status: AutorReplyCacheStatus, message?: string) { + const cacheData = GlobleCache.getCache(AutoReplyCache.cacheKey); + if (cacheData) { + GlobleCache.setCache(AutoReplyCache.cacheKey, { + ...cacheData, + status, + message, + updateTime: new Date().getTime(), + }); + } + } + + // 删除 + delete() { + GlobleCache.delCache(AutoReplyCache.cacheKey); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/controller.ts new file mode 100644 index 000000000..f056e17d5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/controller.ts @@ -0,0 +1,390 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-03-24 09:34:03 + * @LastEditors: nevin + * @Description: reply Reply + */ +import windowOperate from '../../util/windowOperate'; +import { AutoRunModel, AutoRunType } from '../../db/models/autoRun'; +import { AccountService } from '../account/service'; +import { AutoRunService } from '../autoRun/service'; +import { Controller, Et, Icp, Inject } from '../core/decorators'; +import platController from '../plat'; +import { ReplyService } from './service'; +import { SendChannelEnum } from '../../../commont/UtilsEnum'; +import { AutorReplyCommentScheduleEvent } from '../../../commont/types/reply'; +import type { WorkData } from '../plat/plat.type'; +import { GlobleCache } from '../../global/cache'; +import { AutoReplyCache } from './cacheData'; +import type { CorrectQuery } from '../../global/table'; +import { PlatType } from '../../../commont/AccountEnum'; +import { getUserInfo } from '../user/comment'; +@Controller() +export class ReplyController { + @Inject(ReplyService) + private readonly replyService!: ReplyService; + + @Inject(AccountService) + private readonly accountService!: AccountService; + + @Inject(AutoRunService) + private readonly autoRunService!: AutoRunService; + + /** + * 作品点赞 + */ + @Icp('ICP_DIANZAN_DY_OTHER') + async dianzanDyOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + dataId: string, + option?: any, + ) { + const account = await this.accountService.getAccountById(accountId); + + if (!account) + return { + list: [], + count: 0, + }; + + const res = await platController.dianzanDyOther(account, dataId, option); + + return res; + } + + /** + * 作品收藏 + */ + @Icp('ICP_SHOUCANG_DY_OTHER') + async shoucangDyOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + pcursor?: string, + ) { + const account = await this.accountService.getAccountById(accountId); + + if (!account) + return { + list: [], + count: 0, + }; + + const res = await platController.shoucangDyOther(account, pcursor); + + return res; + } + + /** + * 作品列表 + */ + @Icp('ICP_CREATOR_LIST') + async getCreatorList( + event: Electron.IpcMainInvokeEvent, + accountId: number, + pcursor?: string, + ) { + const account = await this.accountService.getAccountById(accountId); + + if (!account) + return { + list: [], + count: 0, + }; + + const res = await platController.getWorkList(account, pcursor); + + return res; + } + + /** + * 搜索列表 + */ + @Icp('ICP_SEARCH_NODE_LIST') + async getSearchNodeList( + event: Electron.IpcMainInvokeEvent, + accountId: number, + qe?: string, // 搜索内容 + pageInfo?: any, + ) { + const account = await this.accountService.getAccountById(accountId); + + if (!account) + return { + list: [], + count: 0, + }; + + const res = await platController.getsearchNodeList(account, qe, pageInfo); + + return res; + } + + /** + * 获取评论列表 + */ + @Icp('ICP_COMMENT_LIST') + async getCommentList( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + pcursor?: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + console.log('------ 评论列表 start ------'); + + const res = await platController.getCommentList(account, data, pcursor); + console.log('------ 评论列表 end ------', res); + + return res; + } + + /** + * 获取其他人评论列表 + */ + @Icp('ICP_COMMENT_LIST_BY_OTHER') + async getCommentListByOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + pcursor?: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.getCreatorCommentListByOther( + account, + data, + pcursor, + ); + return res; + } + + /** + * 获取二级评论列表 + */ + @Icp('ICP_SECOND_COMMENT_LIST_BY_OTHER') + async getSecondCommentListByOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + root_comment_id: string, + pcursor?: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.getCreatorSecondCommentListByOther( + account, + data, + root_comment_id, + pcursor, + ); + return res; + } + + /** + * 创建评论 + */ + @Icp('ICP_CREATE_COMMENT') + async createComment( + event: Electron.IpcMainInvokeEvent, + accountId: number, + dataId: string, + content: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.createComment(account, dataId, content); + return res; + } + + /** + * 创建评论 + */ + @Icp('ICP_CREATE_COMMENT_BY_OTHER') + async createCommentByOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + dataId: string, + content: string, + authorId?: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.createCommentByOther( + account, + dataId, + content, + authorId, + ); + return res; + } + + /** + * 作品一键AI评论(该进程只同时进行一个) + */ + @Icp('ICP_REPLY_COMMENT_LIST_BY_AI') + async createCommentList( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + if (GlobleCache.getCache('replyCommentListByAi')) return null; + + GlobleCache.setCache(`replyCommentListByAi`, true, 60 * 60); + + const res = await this.replyService.autorReplyComment( + account, + data, + (e: { + tag: AutorReplyCommentScheduleEvent; + status: -1 | 0 | 1; + data?: any; + error?: any; + }) => { + windowOperate.sendRenderMsg(SendChannelEnum.CommentRelyProgress, e); + }, + ); + + GlobleCache.delCache(`replyCommentListByAi`); + + return res; + } + + /** + * 回复二级评论 + */ + @Icp('ICP_REPLY_COMMENT_BY_OTHER') + async replyCommentByOther( + event: Electron.IpcMainInvokeEvent, + accountId: number, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + videoAuthId?: string; // 视频作者ID + }, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.replyCommentByOther( + account, + commentId, + content, + option, + ); + return res; + } + + /** + * 回复评论 + */ + @Icp('ICP_REPLY_COMMENT') + async replyComment( + event: Electron.IpcMainInvokeEvent, + accountId: number, + commentId: string, + content: string, + option: { + dataId?: string; // 作品ID + comment: any; // 辅助数据,原数据 + }, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await platController.replyComment( + account, + commentId, + content, + option, + ); + return res; + } + + /** + * 创建自动一键评论任务 + */ + @Icp('ICP_AUTO_RUN_CREATE_REPLY') + async createReplyCommentAutoRun( + event: Electron.IpcMainInvokeEvent, + accountId: number, + data: WorkData, + cycleType: string, + ): Promise { + const account = await this.accountService.getAccountById(accountId); + if (!account) return null; + + const res = await this.autoRunService.createAutoRun( + { + accountId, + cycleType, + type: AutoRunType.ReplyComment, + userId: account.uid, + }, + data, + ); + + return res; + } + + // 运行自动评论任务 + @Et('ET_AUTO_RUN_REPLY_COMMENT') + async runAutoReplyComment(autoRunData: AutoRunModel): Promise { + const { accountId, dataInfo } = autoRunData; + if (!dataInfo) return false; + + const account = await this.accountService.getAccountById(accountId); + if (!account) return false; + + const res = await this.replyService.addReplyQueue( + account, + dataInfo as WorkData, + autoRunData, + ); + + return res.status === 1; + } + + /** + * 获取一键回复评论的任务信息 + */ + @Icp('ICP_GET_AUTO_REPLY_INFO') + async getAutoReplyInfo( + event: Electron.IpcMainInvokeEvent, + ): Promise { + return AutoReplyCache.getInfo(); + } + + /** + * 获取回复评论的记录列表 + */ + @Icp('ICP_GET_REPLAY_COMMENT_RECORD_LIST') + async getReplyCommentRecordList( + event: Electron.IpcMainInvokeEvent, + page: CorrectQuery, + query: { + accountId?: number; + type?: PlatType; + }, + ): Promise { + const userInfo = getUserInfo(); + + return this.replyService.getReplyCommentRecordList( + userInfo.id, + page, + query, + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/module.ts new file mode 100644 index 000000000..e51af5135 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/module.ts @@ -0,0 +1,19 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-03-20 22:26:30 + * @LastEditors: nevin + * @Description: reply Reply 评论 + */ +import { AccountModule } from '../account/module'; +import { AutoRunModule } from '../autoRun/module'; +import { Module } from '../core/decorators'; +import { ReplyController } from './controller'; +import { ReplyService } from './service'; + +@Module({ + imports: [AccountModule, AutoRunModule], + controllers: [ReplyController], + providers: [ReplyService], +}) +export class ReplyModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/service.ts new file mode 100644 index 000000000..88f8939d4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/reply/service.ts @@ -0,0 +1,332 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: Reply reply + */ +import { Inject, Injectable } from '../core/decorators'; +import PQueue from 'p-queue'; +import { AccountModel } from '../../db/models/account'; +import platController from '../plat'; +import { toolsApi } from '../api/tools'; +import { AutoRunService } from '../autoRun/service'; +import { AutoRunModel } from '../../db/models/autoRun'; +import { sysNotice } from '../../global/notice'; +import { AutorReplyCommentScheduleEvent } from '../../../commont/types/reply'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { ReplyCommentRecordModel } from '../../db/models/replyCommentRecord'; +import { AppDataSource } from '../../db'; +import { getUserInfo } from '../user/comment'; +import { AutoRunRecordStatus } from '../../db/models/autoRunRecord'; +import { WorkData } from '../plat/plat.type'; +import { sleep } from '../../util/time'; +import { AutoReplyCache, AutorReplyCacheStatus } from './cacheData'; +import { logger } from '../../global/log'; +import { backPageData, CorrectQuery } from '../../global/table'; +import { PlatType } from '../../../commont/AccountEnum'; + +@Injectable() +export class ReplyService { + replyQueue: PQueue; + private replyCommentRecordRepository: Repository; + + constructor() { + this.replyQueue = new PQueue({ concurrency: 1 }); + this.replyCommentRecordRepository = AppDataSource.getRepository( + ReplyCommentRecordModel, + ); + } + + @Inject(AutoRunService) + private readonly autoRunService!: AutoRunService; + + /** + * 创建评论回复记录 + * @param userId + * @param account + * @param comment + * @returns + */ + async createReplyCommentRecord( + userId: string, + account: AccountModel, + comment: { + id: string; + commentContent: string; + replyContent: string; + }, + ) { + return await this.replyCommentRecordRepository.save({ + userId, + accountId: account.id, + type: account.type, + commentId: comment.id + '', + commentContent: comment.commentContent, + replyContent: comment.replyContent, + }); + } + + // 获取平台的评论记录 + async getReplyCommentRecord( + userId: string, + account: AccountModel, + commentId: string, + ) { + return await this.replyCommentRecordRepository.findOne({ + where: { + userId, + accountId: account.id, + type: account.type, + commentId: commentId + '', + }, + }); + } + + // 获取评论回复记录列表 + async getReplyCommentRecordList( + userId: string, + page: CorrectQuery, + query: { + accountId?: number; + type?: PlatType; + }, + ) { + const filter: FindOptionsWhere = { + userId, + ...(query.accountId && { accountId: query.accountId }), + ...(query.type && { type: query.type }), + }; + + const [list, totalCount] = + await this.replyCommentRecordRepository.findAndCount({ + where: filter, + }); + + return backPageData(list, totalCount, page); + } + + /** + * 自动一键评论 + * 规则:评论所有的一级评论,已经在评论记录的不评论 + */ + async autorReplyComment( + account: AccountModel, + data: WorkData, + scheduleEvent: (data: { + tag: AutorReplyCommentScheduleEvent; + status: -1 | 0 | 1; // -1 错误 0 进行中 1 完成 + data?: any; // 数据 + error?: any; + }) => void, + ) { + const userInfo = getUserInfo(); + let theHasMore = true; + let thePcursor = undefined; + + // 设置缓存数据 + const cacheData = new AutoReplyCache({ + title: data.title || data.desc || '无', + dataId: data.dataId, + }); + + try { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.Start, + status: 0, + }); + + while (theHasMore) { + cacheData.extendTTL(); // 延长缓存时间 + + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.GetCommentListStart, + status: 0, + }); + + // 1. 获取评论列表 + const { + list, + pageInfo: { pcursor, hasMore }, + } = await platController.getCommentList(account, data, thePcursor); + + if (list.length === 0) { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.End, + status: 0, + }); + break; + } + + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.GetCommentListEnd, + status: 0, + }); + + // 2. 循环AI回复评论 + for (const element of list) { + // 判断是否已经回复 + const oldRecord = await this.getReplyCommentRecord( + userInfo.id, + account, + element.commentId, + ); + if (oldRecord) continue; + + // 判断是否已经回复 + let hadReply = false; + for (const reply of element.subCommentList) { + if (account.uid === reply.userId) { + hadReply = true; + break; + } + } + if (!!hadReply) continue; + + const aiRes = await toolsApi.aiRecoverReview({ + content: element.content, + }); + // AI接口错误 + if (!aiRes) { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.Error, + status: -1, + error: '未获得AI产出内容', + }); + cacheData.updateStatus( + AutorReplyCacheStatus.REEOR, + '未获得AI产出内容', + ); + return false; + } + + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.ReplyCommentStart, + data: { + content: element.content, + aiContent: aiRes, + }, + status: 0, + }); + + // 进行回复 + const replyRes = await platController.replyComment( + account, + element.commentId, + aiRes, + { + dataId: data.dataId, + comment: element, + }, + ); + // 错误处理 + if (!replyRes) { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.ReplyCommentEnd, + status: -1, + error: '回复评论失败', + }); + continue; + } + + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.ReplyCommentEnd, + status: 0, + }); + + // 创建评论记录 + this.createReplyCommentRecord(userInfo.id, account, { + id: element.commentId, + commentContent: element.content, + replyContent: aiRes, + }); + + // 延迟 + await sleep(10 * 1000); + } + + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.ReplyCommentEnd, + status: 0, + }); + + thePcursor = pcursor; + theHasMore = !!hasMore; + + if (!theHasMore) { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.End, + status: 0, + }); + return true; + } + } + } catch (error) { + scheduleEvent({ + tag: AutorReplyCommentScheduleEvent.Error, + status: -1, + error, + }); + logger.error(['自动一键评论发生错误', error]); + cacheData.updateStatus(AutorReplyCacheStatus.REEOR, '进行中发生错误'); + return false; + } + + // 清除缓存 + cacheData.delete(); + } + + /** + * 添加作品回复评论的任务到队列 + * @param account + * @param data + * @param autoRun + */ + async addReplyQueue( + account: AccountModel, + data: WorkData, + autoRun: AutoRunModel, + ): Promise<{ + status: 0 | 1; + message?: string; + }> { + // 创建任务执行记录 + const recordData = await this.autoRunService.createAutoRunRecord(autoRun); + + // 添加到队列 + this.replyQueue.add(() => { + this.autorReplyComment( + account, + data, + (e: { + tag: AutorReplyCommentScheduleEvent; + status: -1 | 0 | 1; + error?: any; + }) => { + if (e.tag === AutorReplyCommentScheduleEvent.Start) { + sysNotice('自动评论回复任务执行开始', `任务ID:${autoRun.id}`); + } + + if (e.tag === AutorReplyCommentScheduleEvent.End) { + sysNotice('自动评论回复任务执行结束', `任务ID:${autoRun.id}`); + this.autoRunService.updateAutoRunRecordStatus( + recordData.id, + AutoRunRecordStatus.SUCCESS, + ); + } + + if (e.tag === AutorReplyCommentScheduleEvent.Error) { + sysNotice('自动评论回复任务-错误!!!', `任务ID:${autoRun.id}`); + this.autoRunService.updateAutoRunRecordStatus( + recordData.id, + AutoRunRecordStatus.FAIL, + ); + } + }, + ); + }); + + return { + status: 1, + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/service.ts new file mode 100644 index 000000000..fd3560440 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/service.ts @@ -0,0 +1,20 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 应用的服务 + */ +import { app } from 'electron'; +import { Injectable } from './core/decorators'; +import os from 'os'; + +@Injectable() +export class AppService { + getAppInfo() { + const platform = os.platform(); + return { + version: app.getVersion(), + platform: platform, + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/splash.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/splash.ts new file mode 100644 index 000000000..29dbf90a1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/splash.ts @@ -0,0 +1,60 @@ +/* + * @Author: nevin + * @Date: 2025-02-27 12:12:19 + * @LastEditTime: 2025-02-27 16:38:08 + * @LastEditors: nevin + * @Description: + */ +import { BrowserWindow } from 'electron'; +import path from 'node:path'; +import { VITE_DEV_SERVER_URL, RENDERER_DIST } from './index'; + +export class SplashWindow { + private window: BrowserWindow | null = null; + + create() { + this.window = new BrowserWindow({ + width: 400, + height: 400, + transparent: true, + frame: false, + alwaysOnTop: true, + skipTaskbar: true, + center: true, + // backgroundColor: 'var(--whiteColor1)', // 添加背景色 + webPreferences: { + nodeIntegration: true, + contextIsolation: false, + }, + }); + + // 根据开发环境和生产环境使用不同的加载方式 + const splashPath = VITE_DEV_SERVER_URL + ? path.join(process.env.VITE_PUBLIC!, 'splash.html') + : path.join(RENDERER_DIST, 'splash.html'); + + console.log('Splash path:', splashPath); // 调试路径 + + this.window.loadFile(splashPath).catch((err) => { + console.error('Failed to load splash window:', err); + }); + + // 只在开发环境打印日志 + if (VITE_DEV_SERVER_URL) { + this.window.webContents.on('did-finish-load', () => { + console.log('Splash window loaded'); + }); + + this.window.webContents.on('did-fail-load', (_, code, description) => { + console.error('Splash window failed to load:', code, description); + }); + } + } + + close() { + if (this.window) { + this.window.close(); + this.window = null; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/controller.ts new file mode 100644 index 000000000..be2869d81 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/controller.ts @@ -0,0 +1,38 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-04-01 18:17:38 + * @LastEditors: nevin + * @Description: Test test + */ +import { Controller, Icp, Inject } from '../core/decorators'; +import { TestService } from './service'; +import { EtEvent } from '../../global/event'; + +@Controller() +export class TestController { + @Inject(TestService) + private readonly testService!: TestService; + + /** + * 测试-抖音登录 + */ + @Icp('ICP_GET_FILE_MATE_INFO') + async testDouyinVideoLogin(event: Electron.IpcMainInvokeEvent): Promise { + console.log('---- res ----', 1); + EtEvent.emit('ET_TRACING_ACCOUNT_ADD', { + id: 111, + desc: '添加账户', + }); + + return 1; + } + + /** + * 每10秒运行一次 + */ + // @Scheduled('0/10 * * * * *') + async testScheduleJob(): Promise { + console.log('---- Scheduled test ----'); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/module.ts new file mode 100644 index 000000000..6848e3884 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-02-06 19:14:24 + * @LastEditors: nevin + * @Description: + */ +import { Module } from '../core/decorators'; +import { TestController } from './controller'; +import { TestService } from './service'; + +@Module({ + controllers: [TestController], + providers: [TestService], +}) +export class TestModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/service.ts new file mode 100644 index 000000000..7487ff775 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/test/service.ts @@ -0,0 +1,23 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 用户服务 + */ +import { AppDataSource } from '../../db'; +import { Injectable } from '../core/decorators'; +import { Repository } from 'typeorm'; +import { AccountModel } from '../../db/models/account'; + +@Injectable() +export class TestService { + private accountRepository: Repository; + constructor() { + this.accountRepository = AppDataSource.getRepository(AccountModel); + } + + // 获取用户 + async getInfoById(id: number) { + return await this.accountRepository.findOne({ where: { id } }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/controller.ts new file mode 100644 index 000000000..a985842d0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/controller.ts @@ -0,0 +1,78 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-03-31 12:26:51 + * @LastEditors: nevin + * @Description: + */ +import { FileUtils } from '../../util/file'; +import { FFmpegVideoUtil } from '../../util/ffmpeg/video'; +import { Controller, Icp, Inject, Scheduled } from '../core/decorators'; +import { ToolsService } from './service'; +import { clearOldLogs } from '../../global/log'; +import { toolsApi } from '../api/tools'; + +@Controller() +export class ToolsController { + @Inject(ToolsService) + private readonly toolsService!: ToolsService; + + /** + * 视频截取指定时间点的帧 + */ + @Icp('ICP_GET_VIDEO_COVER') + async getVideoCover( + event: Electron.IpcMainInvokeEvent, + path: string, + time?: string, // 添加可选参数 time + ): Promise { + return await FFmpegVideoUtil.getVideoCover(path, time); + } + + /** + * 下载文件 + */ + @Icp('ICP_TOOL_DOWN_FILE') + async downFile( + event: Electron.IpcMainInvokeEvent, + url: string, + name?: string, + ): Promise { + try { + const res = await FileUtils.downFile(url, name); + return res; + } catch (error) { + console.log('--- ICP_TOOL_DOWN_FILE error ---', error); + return ''; + } + } + + /** + * 下载文件 + */ + @Icp('ICP_TOOL_UP_FILE') + async upFile( + event: Electron.IpcMainInvokeEvent, + path: string, + secondPath?: string, + ): Promise { + try { + const res = await toolsApi.upFile(path, secondPath); + console.log('----- ICP_TOOL_UP_FILE ---', res); + + return res; + } catch (error) { + console.log('--- ICP_TOOL_UP_FILE error ---', error); + return ''; + } + } + + // 定时清理日志, 每小时进行 + @Scheduled('0 0 * * * *', 'clearLog') + async clearLog() { + console.log('clear Log ing ...'); + clearOldLogs(); + } + + +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/module.ts new file mode 100644 index 000000000..e0d4cf755 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-02-06 19:13:48 + * @LastEditors: nevin + * @Description: 工具箱模块 + */ +import { Module } from '../core/decorators'; +import { ToolsController } from './controller'; +import { ToolsService } from './service'; + +@Module({ + controllers: [ToolsController], + providers: [ToolsService], +}) +export class ToolsModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/service.ts new file mode 100644 index 000000000..59f7cdc44 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tools/service.ts @@ -0,0 +1,12 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 工具箱 + */ +import { Inject, Injectable } from '../core/decorators'; + + +@Injectable() +export class ToolsService { +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/controller.ts new file mode 100644 index 000000000..e125ebbc2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/controller.ts @@ -0,0 +1,31 @@ +import { tracingApi } from '../api/tracing'; +import { Controller, Et, Inject } from '../core/decorators'; +import { TracingService } from './service'; + +@Controller() +export class TracingController { + @Inject(TracingService) + private readonly tracingService!: TracingService; + + // 创建跟踪记录-账号添加 + @Et('ET_TRACING_ACCOUNT_ADD') + async tracingAccountAdd(data: { id: number; desc?: string }): Promise { + tracingApi.createTracingAccountAdd(data); + } + + // 创建跟踪记录-视频发布 + @Et('ET_TRACING_VIDEO_PUL') + async tracingVideoPul(data: { + accountId: number; + dataId: string; // 视频发布数据ID + desc?: string; + }): Promise { + tracingApi.createTracingVideoPul(data); + } + + // 创建跟踪记录-开源项目调用 + @Et('ET_TRACING_OPENPROJECT_USE') + async tracingOpenProjectUse(data: { desc?: string }): Promise { + tracingApi.createTracingOpenProjectUse(data); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/module.ts new file mode 100644 index 000000000..24653e0f2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/module.ts @@ -0,0 +1,9 @@ +import { Module } from '../core/decorators'; +import { TracingController } from './controller'; +import { TracingService } from './service'; + +@Module({ + controllers: [TracingController], + providers: [TracingService], +}) +export class TracingModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/service.ts new file mode 100644 index 000000000..1cd4f6f4b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/tracing/service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '../core/decorators'; + +@Injectable() +export class TracingService {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/update.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/update.ts new file mode 100644 index 000000000..84c01c5c5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/update.ts @@ -0,0 +1,94 @@ +/* + * @Author: nevin + * @Date: 2025-01-17 19:25:29 + * @LastEditTime: 2025-01-20 11:15:53 + * @LastEditors: nevin + * @Description: 框架更新 + */ +import { app, ipcMain } from 'electron'; +import { createRequire } from 'node:module'; +import type { + ProgressInfo, + UpdateDownloadedEvent, + UpdateInfo, +} from 'electron-updater'; + +const { autoUpdater } = createRequire(import.meta.url)('electron-updater'); + +export function update(win: Electron.BrowserWindow) { + // When set to false, the update download will be triggered through the API + autoUpdater.autoDownload = false; + autoUpdater.disableWebInstaller = false; + autoUpdater.allowDowngrade = false; + + // start check + autoUpdater.on('checking-for-update', function () {}); + // update available + autoUpdater.on('update-available', (arg: UpdateInfo) => { + win.webContents.send('update-can-available', { + update: true, + version: app.getVersion(), + newVersion: arg?.version, + }); + }); + // update not available + autoUpdater.on('update-not-available', (arg: UpdateInfo) => { + win.webContents.send('update-can-available', { + update: false, + version: app.getVersion(), + newVersion: arg?.version, + }); + }); + + // Checking for updates + ipcMain.handle('check-update', async () => { + if (!app.isPackaged) { + const error = new Error( + 'The update feature is only available after the package.', + ); + return { message: error.message, error }; + } + + try { + return await autoUpdater.checkForUpdatesAndNotify(); + } catch (error) { + return { message: 'Network error', error }; + } + }); + + // Start downloading and feedback on progress + ipcMain.handle('start-download', (event: Electron.IpcMainInvokeEvent) => { + startDownload( + (error, progressInfo) => { + if (error) { + // feedback download error message + event.sender.send('update-error', { message: error.message, error }); + } else { + // feedback update progress message + event.sender.send('download-progress', progressInfo); + } + }, + () => { + // feedback update downloaded message + event.sender.send('update-downloaded'); + }, + ); + }); + + // Install now + ipcMain.handle('quit-and-install', () => { + autoUpdater.quitAndInstall(false, true); + }); +} + +function startDownload( + callback: (error: Error | null, info: ProgressInfo | null) => void, + complete: (event: UpdateDownloadedEvent) => void, +) { + autoUpdater.on('download-progress', (info: ProgressInfo) => + callback(null, info), + ); + autoUpdater.on('error', (error: Error) => callback(error, null)); + autoUpdater.on('update-downloaded', complete); + autoUpdater.downloadUpdate(); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/comment.ts new file mode 100644 index 000000000..8500f0bb7 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/comment.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2025-01-21 21:12:52 + * @LastEditTime: 2025-02-21 21:13:19 + * @LastEditors: nevin + * @Description: + */ +import { store } from '../../global/store'; +import { IUserStore } from '@/store/user'; +import { IUserInfo } from '@/api/types/user-t'; +import { StoreKey } from '../../../src/utils/StroeEnum'; + +// 完整的响应数据接口 +interface ResponseData { + state: IUserStore; + version: number; +} + +export function getUserInfo(): IUserInfo { + const res: ResponseData = JSON.parse(store.get(StoreKey.User)); + return res.state.userInfo!; +} + +export function getUserToken() { + const res: ResponseData = JSON.parse(store.get(StoreKey.User)); + return res.state.token; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/controller.ts new file mode 100644 index 000000000..096eb29e4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/controller.ts @@ -0,0 +1,41 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 22:02:54 + * @LastEditTime: 2025-02-06 19:14:12 + * @LastEditors: nevin + * @Description: + */ +import { Controller, Icp, Inject } from '../core/decorators'; +import { UserService } from './service'; +import { UserModel } from '../../db/models/user'; + +@Controller() +export class UserController { + @Inject(UserService) + private readonly userService!: UserService; + + /** + * 添加用户 + */ + @Icp('ICP_USER_ADD') + async addUser( + event: Electron.IpcMainInvokeEvent, + user: UserModel, + ): Promise { + const currUser = { + ...user, + phone: user.phone || user.wxOpenId, + loginTime: new Date(), + }; + await this.userService.addUser(currUser); + return currUser; + } + + /** + * 获取平台存储的所有用户信息 + */ + @Icp('ICP_USER_ALL') + async getUserList(): Promise { + return await this.userService.getUsers(); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/module.ts new file mode 100644 index 000000000..021d85a05 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/module.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 16:35:59 + * @LastEditTime: 2025-02-06 19:14:24 + * @LastEditors: nevin + * @Description: + */ +import { Module } from '../core/decorators'; +import { UserController } from './controller'; +import { UserService } from './service'; + +@Module({ + controllers: [UserController], + providers: [UserService], +}) +export class UserModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/service.ts new file mode 100644 index 000000000..84dbb9173 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/user/service.ts @@ -0,0 +1,32 @@ +/* + * @Author: nevin + * @Date: 2025-01-24 17:10:35 + * @LastEditors: nevin + * @Description: 用户服务 + */ +import { AppDataSource } from '../../db'; +import { Injectable } from '../core/decorators'; +import { Repository } from 'typeorm'; +import { UserModel } from '../../db/models/user'; + +@Injectable() +export class UserService { + private userRepository: Repository; + constructor() { + this.userRepository = AppDataSource.getRepository(UserModel); + } + // 添加用户 + async addUser(user: UserModel) { + return await this.userRepository.save(user); + } + + // 获取用户 + async getUser(id: string) { + return await this.userRepository.findOne({ where: { id } }); + } + + // 获取所有用户 + async getUsers() { + return await this.userRepository.find(); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/views.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/views.ts new file mode 100644 index 000000000..7c5cd7868 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/main/views.ts @@ -0,0 +1,167 @@ +/* + * @Author: nevin + * @Date: 2025-01-17 21:26:26 + * @LastEditTime: 2025-04-01 17:26:37 + * @LastEditors: nevin + * @Description: 浏览器视图 + */ +import { dialog, ipcMain } from 'electron'; +import fs from 'fs'; +import { FileUtils } from '../util/file'; +import path from 'path'; +import requestNet from '../plat/requestNet'; +// @ts-ignore +import coordtransform from 'coordtransform'; +import { logger } from '../global/log'; +import KwaiPubListener from './plat/platforms/Kwai/KwaiPubListener'; + +export interface ISaveFileParams { + // 要保存的路由 + saveDir: string; + // 文件名 + filename: string; + // 文件 + file: Uint8Array; +} + +export function views(win: Electron.BrowserWindow) { + // 开始监听快手审核发布 + ipcMain.handle('start-kwai-listen', function () { + KwaiPubListener.start(); + }); + + // 窗口最小化 + ipcMain.handle('window-minimize', function () { + win.minimize(); + }); + // 窗口最大化 + ipcMain.handle('window-maximize', function () { + if (win.isMaximized()) { + win.restore(); + } else { + win.maximize(); + } + }); + // 关闭窗口 + ipcMain.handle('window-close', function () { + win.close(); + }); + + // 获取经纬度 + ipcMain.handle('GET_LOCATION', async () => { + let res: any; + + for (let i = 0; i < 10; i++) { + res = await requestNet({ + url: `https://map.baidu.com/?qt=ipLocation&t=${Date.now()}`, + headers: { + cookie: `BAIDUID=C6DFA184EA0E181B507D36D4E39DE552:FG=1; BAIDUID_BFESS=C6DFA184EA0E181B507D36D4E39DE552:FG=1; BAIDU_WISE_UID=wapp_1740893445646_358; ZFY=gFmlsByYQo2uV:A8KgAXQ5Ou6usNB8S4rufvkkSZ9WfE:C; arialoadData=false; RT="z=1&dm=baidu.com&si=6acd8df5-273b-4d7b-a273-72762d6a27a7&ss=m7snsitx&sl=0&tt=0&bcn=https%3A%2F%2Ffclog.baidu.com%2Flog%2Fweirwood%3Ftype%3Dperf&r=32ngg0aq&ul=3pfv&hd=3pg5"; PSTM=1740990133; H_PS_PSSID=61027_61680_62126_62169_62200_62278_62325_62344_62345_62328_62367_62369_62372_62246_62391; BA_HECTOR=24a02la1al052hag8g01a0a1bclj991jsaplo1v; BIDUPSID=256707C2A28F613DE9EA4FC5C7347285`, + }, + method: 'GET', + }); + if (res?.data?.rgc?.result) break; + } + const { lat, lng } = res.data.rgc.result.location; + const gcj02 = coordtransform.bd09togcj02(lng, lat); + return { + bd09: [lng, lat], + wgs84: coordtransform.gcj02towgs84(gcj02[0], gcj02[1]), + gcj02, + city: res.data.rgc.result.addressComponent.city, + }; + }); + + // 打开开发者工具 + ipcMain.handle('OPEN_DEV_TOOLS', () => { + win.webContents.openDevTools({ mode: 'right' }); + }); + + // 保存文件 + ipcMain.handle( + 'ICP_VIEWS_SAVE_FILE', + (event, { saveDir, filename, file }: ISaveFileParams) => { + return new Promise(async (resolve) => { + logger.info('保存文件----111', { saveDir }); + const outputDir = path.join( + FileUtils.getAppDataPath()!, + 'resource/images/cropper', + ); + logger.info('保存文件----222', outputDir); + + await FileUtils.checkDirectories(outputDir + saveDir); + const filePath = outputDir + saveDir + '/' + filename; + logger.info('保存文件----filePath', filePath); + + fs.writeFile(filePath, file, (err) => { + if (err) { + logger.error('保存错误', err); + console.error('保存错误', err); + } else { + logger.info('文件保存成功:', filePath); + console.log('文件保存成功:', filePath); + resolve(filePath); + } + }); + }); + }, + ); + + // 根据路径获取文件流 + ipcMain.handle('ICP_VIEWS_GET_FILE_STREAM', async (event, path: string) => { + return fs.readFileSync(path); + }); + + /** + * 选择视频文件 + * @param isMultiSelections 是否允许多选 + */ + ipcMain.handle('ICP_VIEWS_CHOSE_VIDEO', async (event, isMultiSelections) => { + const properties = ['openFile']; + if (isMultiSelections) properties.push('multiSelections'); + + try { + const result = await dialog.showOpenDialog({ + properties: properties as Array<'openFile'>, + filters: [{ name: 'mp4视频文件', extensions: ['mp4', 'mov'] }], + }); + + if (result.canceled) return null; + return result.filePaths.map((v) => { + return { + path: v, + video: fs.readFileSync(v), + }; + }); + } catch (error) { + console.error('Error selecting video:', error); + return null; + } + }); + + /** + * 选择图片文件 + * @param isMultiSelections 是否允许多选 + */ + ipcMain.handle('ICP_VIEWS_CHOSE_IMG', async (event, isMultiSelections) => { + const properties = ['openFile']; + if (isMultiSelections) properties.push('multiSelections'); + + try { + // 打开文件选择对话框 + const result = await dialog.showOpenDialog({ + properties: properties as Array<'openFile'>, + filters: [{ name: '图片文件', extensions: ['jpg', 'png', 'jpeg'] }], // 只允许图片文件 + }); + + return result.filePaths.map((v) => { + return { + path: v, + file: fs.readFileSync(v), + }; + }); + } catch (error) { + console.error('------- error --------', error); + return ''; + } + }); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/index.ts new file mode 100644 index 000000000..a0670eed5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/index.ts @@ -0,0 +1,769 @@ +import requestNet, { IRequestNetParams } from '../requestNet'; +import { + CommentAddResponse, + GetCommentListResponse, + GetPhotoListResponse, + GetSubCommentListResponse, + GetWorksListResponse, + IGetHomeInfoResponse, + IGetHomeOverview, + IKwaiGetLocationsResponse, + IKwaiGetTopicsResponse, + IKwaiGetUsersResponse, + IKwaiPubVideoParams, + IKwaiUserInfoResponse, + ILoginResponse, + KwaiSubmitResponse, + KwaiWorkItem, + RefreshWorksResponse, + UploadFinishResponse, + UploadPpreResponse, +} from './kwai.type'; +import { CookieToString } from '../utils'; +import { BrowserWindow, screen } from 'electron'; +import kwaiSign from './sign/KwaiSign'; +import { FileUtils } from '../../util/file'; +import { getFilePathNameCommon, RetryWhile } from '../../../commont/utils'; +import fs from 'fs'; +import FormData from 'form-data'; + +interface IRequestApiParams extends IRequestNetParams { + cookie: Electron.Cookie[]; + apiUrl?: string; +} + +class KwaiPub { + fileBlockSize = 4194304; + + // 普通参数转换为快手参数 + convertKwaiParams(params: IKwaiPubVideoParams) { + const kwaiParams: any = {}; + kwaiParams['caption'] = params.desc; + + // 好友处理 + if (params.mentions) { + kwaiParams['caption'] += ` ${params.mentions.join(' ')} `; + } + + // 话题处理 + if (params.topics) { + kwaiParams['caption'] += ` ${params.topics.join(' ')} `; + } + + // 位置处理 + if (params.poiInfo) { + kwaiParams['poiId'] = params.poiInfo.poiId; + kwaiParams['latitude'] = params.poiInfo.latitude; + kwaiParams['longitude'] = params.poiInfo.longitude; + } + + return { + ...kwaiParams, + photoStatus: params.photoStatus, + publishTime: params.publishTime || 0, + }; + } + // 发布视频 + async pubVideo(params: IKwaiPubVideoParams): Promise<{ + publishId: string; + shareLink: string; + }> { + console.log('快手原始参数:', { + ...params, + cookies: null, + }); + return new Promise(async (resolve, reject) => { + try { + const callback = params.callback; + callback(5, '正在视频分片...'); + const { filename, suffix } = getFilePathNameCommon(params.videoPath); + const filePartInfo = await FileUtils.getFilePartInfo( + params.videoPath, + this.fileBlockSize, + ); + callback(10, '正在创建视频...'); + const preRes = await this.requestApi({ + url: '/rest/cp/works/v2/video/pc/upload/pre', + cookie: params.cookies, + method: 'POST', + body: { + uploadType: 1, + }, + proxy: params.proxy, + }); + console.log('创建视频:', preRes); + if (!preRes.data.data) { + throw new Error(preRes.data.message); + } + + for (const i in filePartInfo.blockInfo) { + callback(40, `上传视频(${i}/${filePartInfo.blockInfo.length})`); + + const isSuccess = await RetryWhile(async () => { + const chunkStart = + i === '0' ? 0 : filePartInfo.blockInfo[parseInt(i) - 1]; + const chunkEnd = filePartInfo.blockInfo[i] - 1; + const chunkContent = await FileUtils.getFilePartContent( + params.videoPath, + chunkStart, + chunkEnd, + ); + const uploadVideoRes = await requestNet({ + method: 'POST', + url: `https://upload.kuaishouzt.com/api/upload/fragment?upload_token=${preRes.data.data.token}&fragment_id=${i}`, + isFile: true, + body: chunkContent, + }); + if (uploadVideoRes.data.result === 1) { + return true; + } + }, 3); + if (!isSuccess) { + throw new Error('分片上传失败,请稍后重试!'); + } + } + + callback(60, `查询分片上传结果...`); + const completeRes = await this.requestApi({ + apiUrl: `https://upload.kuaishouzt.com/api/upload/complete?upload_token=${preRes.data.data.token}&fragment_count=${filePartInfo.blockInfo.length}`, + method: 'POST', + cookie: params.cookies, + body: { + uploadType: 1, + }, + proxy: params.proxy, + }); + console.log(`分片上传完成:`, completeRes.data); + + callback(70, `分片上传完成,视频上传结束...`); + const finishRes = await this.requestApi({ + url: `/rest/cp/works/v2/video/pc/upload/finish`, + method: 'POST', + cookie: params.cookies, + body: { + token: preRes.data.data.token, + fileName: filename, + fileType: `video/${suffix}`, + fileLength: filePartInfo.fileSize, + }, + proxy: params.proxy, + }); + if (finishRes.data.result !== 1) + throw new Error(finishRes.data.message); + console.log(`视频上传结束:`, finishRes.data); + + callback(80, `视频上传完成,正在上传封面...`); + const formData = new FormData(); + formData.append('file', fs.createReadStream(params.coverPath)); + const coverRes = await this.requestApi<{ + data: { + coverKey: string; + }; + }>({ + formData, + url: '/rest/cp/works/v2/video/pc/upload/cover/upload', + method: 'POST', + cookie: params.cookies, + proxy: params.proxy, + }); + console.log('上传封面结果:', coverRes.data); + + callback(90, `正在发布...`); + + const submitParams = { + ...finishRes.data.data, + coverKey: coverRes.data.data.coverKey, + coverTimeStamp: 0, + coverType: 1, + coverTitle: '', + photoType: 0, + collectionId: '', + publishTime: 0, + longitude: '', + latitude: '', + poiId: 0, + notifyResult: 0, + domain: '', + secondDomain: '', + coverCropped: false, + pkCoverKey: '', + profileCoverKey: '', + downloadType: 1, + disableNearbyShow: false, + allowSameFrame: true, + movieId: '', + openPrePreview: false, + declareInfo: {}, + activityIds: [], + riseQuality: false, + chapters: [], + projectId: '', + recTagIdList: [], + videoInfoMeta: '', + previewUrlErrorMessage: '', + triggerH265: false, + ...this.convertKwaiParams(params), + }; + console.log('快手最终发布参数:', submitParams); + const submitRes = await this.requestApi({ + url: `/rest/cp/works/v2/video/pc/submit`, + method: 'POST', + cookie: params.cookies, + body: submitParams, + proxy: params.proxy, + }); + console.log(`视频发布完成:`, submitRes.data); + console.log(submitRes.data.result); + if (submitRes.data.result !== 1) { + return reject(submitRes.data.message); + } + callback(95, `正在查询发布结果...`); + let work: KwaiWorkItem | undefined; + await RetryWhile(async () => { + const worksList = await this.getWorks(params.cookies, { + queryType: '2', + limit: 20, + }); + work = worksList.data.data.list.find( + (v) => v.unPublishCoverKey === coverRes.data.data.coverKey, + ); + console.log('快手查询到的作品:', work); + if (work) return true; + }, 5); + console.log('发布成功!'); + resolve({ + shareLink: ``, + publishId: `${work?.publishId}`, + }); + } catch (e: any) { + reject(`发布失败,失败原因:${e.message}`); + console.error(e); + } + }); + } + + // 发送请求 + async requestApi(params: IRequestApiParams) { + const { cookie, apiUrl = 'https://cp.kuaishou.com' } = params; + + const api_ph = cookie.find( + (v) => v.name === 'kuaishou.web.cp.api_ph', + )!.value; + + if (params.formData) { + params.formData.append('kuaishou.web.cp.api_ph', api_ph); + } else if (params.method === 'POST') { + params['body'] = { + ...(params['body'] ? params['body'] : {}), + 'kuaishou.web.cp.api_ph': api_ph, + }; + } + + params['headers'] = { + ...(params['headers'] ? params['headers'] : {}), + cookie: CookieToString(cookie), + }; + + const signUrl = await kwaiSign.sign({ + json: !params.formData + ? params.body || {} + : { + 'kuaishou.web.cp.api_ph': api_ph, + }, + type: params.formData ? 'form-data' : 'json', + url: `${apiUrl}${params.url || ''}`, + }); + + return await requestNet({ + ...params, + url: `${apiUrl == 'https://cp.kuaishou.com' ? signUrl : apiUrl}`, + }); + } + + async getHomeOverview(cookie: Electron.Cookie[]) { + return await this.requestApi({ + cookie, + url: '/rest/cp/creator/analysis/pc/home/author/overview', + method: 'POST', + body: { + timeType: 3, + }, + }); + } + + // 快手登录 + login(): Promise { + return new Promise(async (resolve, reject) => { + const partition = Date.now().toString(); + const { width, height } = screen.getPrimaryDisplay().workAreaSize; + const mainWindow = new BrowserWindow({ + width: Math.ceil(width * 0.8), + height: Math.ceil(height * 0.8), + webPreferences: { + contextIsolation: false, + nodeIntegration: false, + partition, + }, + }); + mainWindow.webContents!.setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36 Edg/135.0.0.0', + ); + mainWindow.webContents.on( + 'console-message', + (event, level, message, line, sourceId) => { + console.error(message); + // 如果需要禁用所有 JavaScript 错误 + if (message.includes('Error')) { + event.preventDefault(); + return; // 忽略错误 + } + }, + ); + // 登录页面 + await mainWindow.loadURL( + 'https://passport.kuaishou.com/pc/account/login', + ); + const session = mainWindow.webContents.session; + await session.clearCache(); + // mainWindow.webContents.openDevTools(); + let timeId1: NodeJS.Timeout | undefined = undefined; + let timeId2: NodeJS.Timeout | undefined = undefined; + mainWindow.on('closed', () => { + clearInterval(timeId1); + clearInterval(timeId2); + }); + timeId1 = setInterval(async () => { + const cookies = await mainWindow.webContents.session.cookies.get({}); + // 存在关键cookie + if (cookies.some((v) => v.name === 'kuaishou.server.webday7_ph')) { + clearInterval(timeId1); + // 开发者后台 + await mainWindow.loadURL( + 'https://cp.kuaishou.com/article/publish/video?origin=www.kuaishou.com', + ); + // 检测创作者中心登录 + timeId2 = setInterval(async () => { + const cookies = await mainWindow.webContents.session.cookies.get( + {}, + ); + // 存在关键cookie + if (cookies.some((v) => v.name === 'ks_onvideo_token')) { + const cookiesLast = + await mainWindow.webContents.session.cookies.get({}); + const userInfoReq = await this.getAccountInfo(cookiesLast); + if (userInfoReq.status === 200 && userInfoReq.data.data) { + clearInterval(timeId2); + mainWindow.close(); + resolve({ + cookies: cookiesLast, + userInfo: userInfoReq, + }); + } else { + clearInterval(timeId2); + reject('获取用户信息失败'); + } + } + }, 500); + } + }, 500); + }); + } + + // 获取账户的粉丝数、关注、获赞 + async getHomeInfo(cookie: Electron.Cookie[]) { + return await this.requestApi({ + cookie: cookie, + url: '/rest/cp/creator/pc/home/infoV2', + method: 'POST', + }); + } + + // 获取账号信息 + async getAccountInfo(cookie: Electron.Cookie[]) { + return await this.requestApi({ + cookie: cookie, + method: 'POST', + apiUrl: 'https://www.kuaishou.com/graphql', + body: { + operationName: 'userInfoQuery', + variables: {}, + query: + 'query userInfoQuery {\n userInfo {\n id\n name\n avatar\n eid\n userId\n __typename\n }\n}\n', + }, + }); + } + + // 获取话题 + async getTopics({ + keyword, + cookies, + }: { + keyword: string; + cookies: Electron.Cookie[]; + }) { + return await this.requestApi({ + cookie: cookies, + url: `/rest/cp/works/v2/video/pc/tag/search`, + method: 'POST', + body: { + keyword, + }, + }); + } + + // 获取关注用户 + async getUsers({ + page, + cookies, + }: { + page: number; + cookies: Electron.Cookie[]; + }) { + return await this.requestApi({ + cookie: cookies, + url: `/rest/cp/works/v2/video/pc/at/list`, + method: 'POST', + body: { + atType: 3, + pageCount: page, + pageSize: 10, + }, + }); + } + + // 获取快手位置 + async getLocations({ + cookies, + cityName, + keyword, + }: { + cookies: Electron.Cookie[]; + cityName: string; + keyword: string; + }) { + return await this.requestApi({ + cookie: cookies, + url: `/rest/zt/location/wi/poi/search?kpn=kuaishou_cp&subBiz=CP%2FCREATOR_PLATFORM&kuaishou.web.cp.api_ph=fe283c2f058ddb7a3098f89511fbd536dd82`, + method: 'POST', + body: { + cityName, + count: 50, + keyword, + pcursor: '', + }, + }); + } + + // 刷新作品 + async refreshWorks(cookies: Electron.Cookie[], ids: number[]) { + return await this.requestApi({ + cookie: cookies, + url: `/rest/cp/works/v2/video/pc/publish/refresh`, + method: 'POST', + body: { + ids, + }, + }); + } + + // 获取作品-开发者后台 + async getWorks( + cookies: Electron.Cookie[], + params: { + // 0=全部作品,1=已发布,2=待发布,3=未通过 + queryType: '0' | '1' | '2' | '3'; + limit?: number; + }, + ) { + return await this.requestApi({ + cookie: cookies, + url: `/rest/cp/works/v2/video/pc/photo/list`, + method: 'POST', + body: { + cursor: Date.now(), + queryType: params.queryType, + limit: params.limit || 100, + timeRangeType: 5, + keyword: '', + startTime: Date.now() - 1000 * 60 * 60 * 24 * 30, + endTime: Date.now(), + }, + }); + } + + /** + * 获取作品列表 + * @param cookies + * @param pcursor + * @returns + */ + async getPhotoList( + cookies: Electron.Cookie[], + pcursor?: number, // 下一页页码 + ) { + const res = await this.requestApi({ + cookie: cookies, + url: `/rest/cp/creator/comment/photoList`, + method: 'POST', + body: { + ...(pcursor ? { pcursor } : {}), + }, + }); + return res; + } + + // 搜索作品列表 + async getsearchNodeList( + cookies: Electron.Cookie[], + qe: string, + pageInfo?: any, + ) { + const bodys: any = { + operationName: 'visionSearchPhoto', + variables: { + keyword: qe, + pcursor: pageInfo.pcursor < 1 ? '' : pageInfo.pcursor + '', + page: 'search', + }, + query: `fragment photoContent on PhotoEntity {\n __typename\n id\n duration\n caption\n originCaption\n likeCount\n viewCount\n commentCount\n realLikeCount\n coverUrl\n photoUrl\n photoH265Url\n manifest\n manifestH265\n videoResource\n coverUrls {\n url\n __typename\n }\n timestamp\n expTag\n animatedCoverUrl\n distance\n videoRatio\n liked\n stereoType\n profileUserTopPhoto\n musicBlocked\n riskTagContent\n riskTagUrl\n}\n\nfragment recoPhotoFragment on recoPhotoEntity {\n __typename\n id\n duration\n caption\n originCaption\n likeCount\n viewCount\n commentCount\n realLikeCount\n coverUrl\n photoUrl\n photoH265Url\n manifest\n manifestH265\n videoResource\n coverUrls {\n url\n __typename\n }\n timestamp\n expTag\n animatedCoverUrl\n distance\n videoRatio\n liked\n stereoType\n profileUserTopPhoto\n musicBlocked\n riskTagContent\n riskTagUrl\n}\n\nfragment feedContent on Feed {\n type\n author {\n id\n name\n headerUrl\n following\n headerUrls {\n url\n __typename\n }\n __typename\n }\n photo {\n ...photoContent\n ...recoPhotoFragment\n __typename\n }\n canAddComment\n llsid\n status\n currentPcursor\n tags {\n type\n name\n __typename\n }\n __typename\n}\n\nquery visionSearchPhoto($keyword: String, $pcursor: String, $searchSessionId: String, $page: String, $webPageArea: String) {\n visionSearchPhoto(keyword: $keyword, pcursor: $pcursor, searchSessionId: $searchSessionId, page: $page, webPageArea: $webPageArea) {\n result\n llsid\n webPageArea\n feeds {\n ...feedContent\n __typename\n }\n searchSessionId\n pcursor\n aladdinBanner {\n imgUrl\n link\n __typename\n }\n __typename\n }\n}\n`, + }; + + if (pageInfo.postFirstId) { + bodys.variables.searchSessionId = pageInfo.postFirstId; + } + + const res = await this.requestApi({ + cookie: cookies, + apiUrl: 'https://www.kuaishou.com/graphql', + method: 'POST', + body: bodys, + headers: { + Referer: `https://www.kuaishou.com/search?keyword=${qe}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + return res; + } + + // 获取评论列表 + async getCommentList( + cookies: Electron.Cookie[], + photoId: string, + pcursor?: number, + ) { + const res = await this.requestApi({ + cookie: cookies, + url: `/rest/cp/creator/comment/commentList`, + method: 'POST', + body: { + photoId, + sortType: '', + selectedComment: false, + ...(pcursor ? { pcursor } : {}), + }, + }); + + return res; + } + + // 获取评论的回复列表 + async getSubCommentList( + cookies: Electron.Cookie[], + photoId: string, + commentId: number, + ) { + const res = await this.requestApi({ + cookie: cookies, + url: `/rest/cp/creator/comment/subCommentList`, + method: 'POST', + body: { + commentId, // 969549966791, + photoId, //'3xsq95w5uxvjx7q', + }, + }); + + return res; + } + + /** + * 添加评论和回复评论 + * @param cookie + * @param content + * @param reply + * @returns + */ + async commentAdd( + cookie: Electron.Cookie[], + content: string, + reply: { + photoId?: string; + replyToCommentId?: number; // 969549966791; + replyTo?: number; // 798319351; + }, + ) { + const res = await this.requestApi({ + cookie: cookie, + url: '/rest/cp/creator/comment/add', + method: 'POST', + body: { + content, + ...reply, + }, + }); + + return res; + } + + /** + * 点赞 + * @param cookie + * @param dataId + * @param option + * @returns + */ + async dianzanDyOther(cookie: Electron.Cookie[], dataId: string, option: any) { + const res = await this.requestApi({ + cookie: cookie, + apiUrl: 'https://www.kuaishou.com/graphql', + method: 'POST', + body: { + operationName: 'visionVideoLike', + variables: { + photoId: dataId, + photoAuthorId: option.authid, + cancel: 0, + expTag: '1_i/2008189535617610417_xpcwebdetailxxnull0', + }, + query: `mutation visionVideoLike($photoId: String, $photoAuthorId: String, $cancel: Int, $expTag: String) {\n visionVideoLike(photoId: $photoId, photoAuthorId: $photoAuthorId, cancel: $cancel, expTag: $expTag) {\n result\n __typename\n }\n}\n`, + }, + headers: { + Referer: `https://www.kuaishou.com/short-video/${dataId}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + + return res; + } + + /** + * 视频评论 + * @param cookie + * @param dataId + * @param content + * @param authorId + * @returns + */ + async videoCommentByOther( + cookie: Electron.Cookie[], + dataId: string, + content: string, + authorId?: string, + ) { + const res = await this.requestApi({ + cookie: cookie, + apiUrl: 'https://www.kuaishou.com/graphql', + method: 'POST', + body: { + operationName: 'visionAddComment', + variables: { + photoId: dataId, + photoAuthorId: authorId, + content: content, + expTag: '1_a/2004436422502146722_xpcwebdetailxxnull0', + }, + query: + 'mutation visionAddComment($photoId: String, $photoAuthorId: String, $content: String, $replyToCommentId: ID, $replyTo: ID, $expTag: String) {\n visionAddComment(photoId: $photoId, photoAuthorId: $photoAuthorId, content: $content, replyToCommentId: $replyToCommentId, replyTo: $replyTo, expTag: $expTag) {\n result\n commentId\n content\n timestamp\n status\n __typename\n }\n}\n', + }, + headers: { + Referer: `https://www.kuaishou.com/short-video/${dataId}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + + return res; + } + + /** + * 获取视频评论列表 + * @param cookie + * @param dataId + * @param pcursor + * @returns + */ + async getVideoCommentList( + cookie: Electron.Cookie[], + dataId: string, + pcursor?: string, + ) { + const res = await this.requestApi({ + cookie: cookie, + apiUrl: 'https://www.kuaishou.com/graphql', + method: 'POST', + body: { + operationName: 'commentListQuery', + variables: { + pcursor: '', + photoId: dataId, + }, + query: + 'query commentListQuery($photoId: String, $pcursor: String) {\n visionCommentList(photoId: $photoId, pcursor: $pcursor) {\n commentCount\n pcursor\n rootComments {\n commentId\n authorId\n authorName\n content\n headurl\n timestamp\n likedCount\n realLikedCount\n liked\n status\n authorLiked\n subCommentCount\n subCommentsPcursor\n subComments {\n commentId\n authorId\n authorName\n content\n headurl\n timestamp\n likedCount\n realLikedCount\n liked\n status\n authorLiked\n replyToUserName\n replyTo\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', + }, + headers: { + Referer: `https://www.kuaishou.com/short-video/${dataId}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + + return res; + } + + /** + * 回复二级评论 + * @param cookie + * @param content + * @param option + * @returns + */ + async replyCommentByOther( + cookie: Electron.Cookie[], + content: string, + option: { + replyToCommentId?: any; // 969549966791; + replyTo?: any; // 798319351; + photoId?: string; // 作品ID + photoAuthorId: any; // 视频作者ID + }, + ) { + const res = await this.requestApi({ + cookie: cookie, + apiUrl: 'https://www.kuaishou.com/graphql', + method: 'POST', + body: { + operationName: 'visionAddComment', + variables: { + photoId: option.photoId, + photoAuthorId: option.photoAuthorId, + content: content, + replyToCommentId: option.replyToCommentId, + replyTo: option.replyTo, + expTag: '1_a/2004436422502146722_xpcwebdetailxxnull0', + }, + query: + 'mutation visionAddComment($photoId: String, $photoAuthorId: String, $content: String, $replyToCommentId: ID, $replyTo: ID, $expTag: String) {\n visionAddComment(photoId: $photoId, photoAuthorId: $photoAuthorId, content: $content, replyToCommentId: $replyToCommentId, replyTo: $replyTo, expTag: $expTag) {\n result\n commentId\n content\n timestamp\n status\n __typename\n }\n}\n', + }, + headers: { + Referer: `https://www.kuaishou.com/short-video/${option.photoId}`, + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + }, + }); + + return res; + } +} + +export const kwaiPub = new KwaiPub(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/kwai.type.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/kwai.type.ts new file mode 100644 index 000000000..f0c1610a4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/kwai.type.ts @@ -0,0 +1,334 @@ +import { IRequestNetResult } from '../requestNet'; + +interface AbConfig { + cpPublishNewPage2024: boolean; + enableGaoXinLaHuiSwitch: boolean; + cpPublishSearch: boolean; + favorAbGroupWithGray: number; + enableNewGrowthTaskV2: boolean; + enableNewHomeVersionV4: boolean; + enablePhotoFavorV2Switch: boolean; +} + +export interface IKwaiUserCommonResponse { + result: number; + currentTime: number; + 'host-name': string; + data: T; + message: string; +} + +// video/pc/upload/pre +export type UploadPpreResponse = IKwaiUserCommonResponse<{ + token: string; + fileId: number; +}>; + +// video/pc/upload/finish +export type UploadFinishResponse = IKwaiUserCommonResponse<{ + coverKey: string; + coverMediaId: string; + duration: number; + fileId: number; + height: number; + mediaId: string; + photoIdStr: string; + videoDuration: string; + videoFrameRate: string; + width: string; +}>; + +// /v2/video/pc/submit +export type KwaiSubmitResponse = IKwaiUserCommonResponse<{ + result: number; + message: string; +}>; + +export interface IGetHomeOverviewItem { + name: string; + tab: string; + sumCount: number; + endDayCount: number; + interpretDesc: string | null; + trendData: { + date: string; + count: number; + }[]; + diagnoseResultType: string; +} + +export interface IGetHomeOverview { + result: number; + currentTime: number; + 'host-name': string; + data: { + basicData: IGetHomeOverviewItem[]; + dataUpdateTime: string; + }; + message: string; +} + +export interface IGetHomeInfoResponse { + data: { + desc: string; + fansCnt: number; + followCnt: number; + likeCnt: number; + userId: number; + userName: string; + userKwaiId?: number; + }; +} + +// 快手用户信息接口返回的数据 +export type IKwaiUserInfoResponse = { + data: { + userInfo: { + avatar: string; + eid: string; + id: string; + name: string; + userId: number; + __typename: string; + }; + }; +}; + +interface TopicsTag { + id: number; + name: string; + data: any; + status: number; + karaoke: any; +} + +// 获取快手话题接口返回的数据 +export type IKwaiGetTopicsResponse = IKwaiUserCommonResponse<{ + result: number; + ussid: string; + pcursor: string; + tags: { + tag: TopicsTag; + viewCount: number; + }[]; +}>; + +// 获取快手关注用户返回的数据 +export type IKwaiGetUsersResponse = IKwaiUserCommonResponse<{ + list: { + userName: string; + userId: number; + fansCount: number; + headUrl: string; + }[]; + name: string; + type: number; +}>; + +// 获取快手位置数据 +export type IKwaiGetLocationsResponse = { + locations: { + id: number; + title: string; + address: string; + city: string; + category: number; + latitude: number; + longitude: number; + idString: string; + }[]; + pcursor: string; + result: number; +}; + +// 快手视频发布入参 +export interface IKwaiPubVideoParams { + // cookies + cookies: Electron.Cookie[]; + // 话题 + topics: string[]; + // 发布视频的简介 + desc: string; + // 视频的路径 + videoPath: string; + // 封面路径 + coverPath: string; + // 发布回调,可以用于获取发布进度 + callback: (progress: number, msg?: string) => void; + // 位置 + poiInfo?: { + poiId: string; + latitude: string; + longitude: string; + }; + // 作品权限 1=所有人可见 2=仅自己可见 4=好友可见 + photoStatus: 1 | 2 | 4; + // @的好友 + mentions?: string[]; + // 定时发布日期,时间戳,毫秒 + publishTime?: number; + // 代理地址 + proxy: string; +} + +// 登录返回参数 +export interface ILoginResponse { + cookies: Electron.Cookie[]; + userInfo: IRequestNetResult; +} + +// 刷新作品 +export type RefreshWorksResponse = IKwaiUserCommonResponse<{ + list: { + id: number; + likeCount: number; + operations: number[]; + playCount: number; + publishStatus: number; + // 如果已经发布这个值会存在 + workId?: string; + }[]; +}>; + +export type KwaiWorkItem = { + publishId: number; + workId: string; + title: string; + publishCoverUrl: string; + unPublishCoverKey: string | null; + userId: number; + userIdStr: string; + userName: string; + userHead: string; + playCount: number; + likeCount: number; + commentCount: number; + uploadTime: number; + durationSecond: number; + judgementTitle: string; + judgementStatus: number; + publishStatus: number; + operations: string[]; + publishType: number; + photoStatus: number; + photoTop: boolean; + collectionTitle: string; + collectionId: number; + awdId: number; + hotspotActivity: null; + hotspot: null; + showAtlasIcon: boolean; + showDuration: boolean; +}; + +// 获取作品,开发者后台 +export type GetWorksListResponse = IKwaiUserCommonResponse<{ + list: KwaiWorkItem[]; +}>; + +// 获取作品列表 +export interface GetPhotoListResponse { + result: number; // 1; + currentTime: number; // 1741842574582; + 'host-name': string; // 'public-bjxy-rs9-kce-node961.idcyz.hb1.kwaidc.com'; + data: { + photoList: { + photoId: string; // '3xsq95w5uxvjx7q'; + title: string; // ''; + cover: string; // 'https://p2.a.yximgs.com/upic/2025/02/24/21/BMjAyNTAyMjQyMTQ4NDFfNzk4MzE5MzUxXzE1Nzc4MzY4MTM2MV8wXzM=_B05f8067d6793fd47dcbb196af577f7ae.jpg?tag=1-1741842574-nil-0-uacnaa9n5n-f19cc27a1c6378c5&clientCacheKey=3xsq95w5uxvjx7q.jpg&di=b7c6874b&bp=10000'; + playCount: number; // 6; + likeCount: number; // 0; + commentCount: number; // 2; + uploadTime: number; // 1740404944454; + duration: number; // 13300; + isVideo: true; + isSettingSelectedComment: boolean; // false; + photoSelectedTips: any; // null; + }[]; + pcursor: number; // 1515253882683; // 下一页页码 + totalCount: number; // 2; + visionSearchPhoto: { + feeds: any[]; + }; + result?: string; + }; + message: string; // '成功'; +} + +// 获取评论列表 +export interface GetCommentListResponse { + result: number; // 1; + currentTime: number; // 1741843263417; + 'host-name': string; // 'public-bjxy-rs9-kce-node961.idcyz.hb1.kwaidc.com'; + data: { + pcursor?: number; // 971109198576; + list: [ + { + photoId: number; // 157783681361; + authorId: number; // 798319351; + headurl: string; // 'https://p66-pro.a.yximgs.com/uhead/AB/2018/01/06/23/BMjAxODAxMDYyMzQ4MjRfNzk4MzE5MzUxXzJfaGQ4OTBfMTcz_s.jpg'; + authorName: string; // '墨2668'; + commentId: number; // 969549966791; + content: string; // '哈哈哈'; + replyTo: number; // 0; + replyToUserName: any; // null; + replyToCommentId: number; // 0; + timestamp: number; // 1741695330721; + likedCount: number; // 0; + liked: boolean; // false; + subCommentCount: number; // 1; + emotionId: any; // null; + emotion: any; // null; + ip: number; // 0; + referer: any; // null; + toped: boolean; // false; + settingSelectedComment: boolean; // false; + isSettingSelectedComment: boolean; // false; + }, + ]; + }; + message: string; // '成功'; +} + +// 获取子回复列表 +export interface GetSubCommentListResponse { + result: number; // 1; + currentTime: number; // 1741843273188; + 'host-name': string; // 'public-bjx-c26-kce-node710.idchb1az1.hb1.kwaidc.com'; + data: { + list: { + photoId: number; // 157783681361; + authorId: number; // 798319351; + headurl: string; // 'https://p66-pro.a.yximgs.com/uhead/AB/2018/01/06/23/BMjAxODAxMDYyMzQ4MjRfNzk4MzE5MzUxXzJfaGQ4OTBfMTcz_s.jpg'; + authorName: string; // '墨2668'; + commentId: number; // 969618657810; + content: string; // '666'; + replyTo: number; // 798319351; + replyToUserName: string; // '墨2668'; + replyToCommentId: number; // 0; + timestamp: number; // 1741704937506; + likedCount: number; // 0; + liked: boolean; // false; + subCommentCount: number; // 0; + emotionId: any; // null; + emotion: any; // null; + ip: number; // 0; + referer: any; // null; + toped: boolean; // false; + settingSelectedComment: boolean; // false; + isSettingSelectedComment: boolean; // false; + }[]; + }; + message: string; // '成功'; +} + +// 创建评论返回参数 +export interface CommentAddResponse { + result: number; // 1; + currentTime: number; // 1741704937529; + 'host-name': string; // 'public-bjx-c26-kce-node717.idchb1az1.hb1.kwaidc.com'; + data: { + commentId: number; // 969618657810; + }; + message: string; // '成功'; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/KwaiSign.ts b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/KwaiSign.ts new file mode 100644 index 000000000..bc409ebbc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/KwaiSign.ts @@ -0,0 +1,69 @@ +// @ts-ignore +import kuaiShosignCore from './kuaiShoSignCore.js'; +import qs from 'qs'; +// @ts-ignore +import crypto from 'crypto-js'; + +interface ISignParams { + json: Record; + type: 'form-data' | 'json'; + url: string; +} + +// 快手平台签名 +class KwaiSign { + exports: any = {}; + + constructor() { + const obj = { + exports: {}, + id: 75407, + loaded: true, + }; + kuaiShosignCore[75407](obj); + this.exports = obj.exports; + } + + /** + * 输入: + * sign({ + * url: '/rest/cp/creator/comment/report/menu', + * type: 'json', + * json: { + * 'kuaishou.web.cp.api_ph': '19af6d5b24cb170a03331ce9254b1204154c', + * }, + * }) + * 输出: + * /rest/cp/creator/comment/report/menu?__NS_sig3=4656112138efc7727e1b18193ee992f9c3278f63070705050a0b0812 + * @param params + */ + sign(params: ISignParams) { + return new Promise(async (resolve, reject) => { + const { url } = params; + const md5 = this.md5(params); + + this.exports.realm.global['$encode'](md5, { + suc(s: string) { + resolve(`${url}?__NS_sig3=${s}`); + }, + err(e: string) { + console.error('签名失败:', e); + reject(e); + }, + }); + }); + } + + private md5({ json, type }: ISignParams) { + let str = ''; + if (type === 'form-data') { + str = qs.stringify(json); + } else { + str = JSON.stringify(json); + } + return crypto.MD5(str).toString(); + } +} + +const kwaiSign = new KwaiSign(); +export default kwaiSign; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/kuaiShoSignCore.js b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/kuaiShoSignCore.js new file mode 100644 index 000000000..a9ef78bd2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/electron/plat/Kwai/sign/kuaiShoSignCore.js @@ -0,0 +1,677 @@ +const window = {}; +export default { + 75407: e => { + ! function(t, n) { + e.exports = n() + }(window, (function() { + return n = {}, e.m = t = [function(e, t) { + (function() { + var e = function(e) { + return e.constructor.prototype + }, + n = Object.create, + r = function(e, t) { + return Object.prototype.hasOwnProperty.call(e, t) + }, + i = Array.isArray, + o = function(e, t, n) { + return Object.defineProperty(e, t, n) + }; + t.prototypeOf = e, t.create = n, t.hasProp = r, t.isArray = i, t.defProp = o + }).call(this) + }, function(e, t) { + (function() { + function e(e) { + this.elements = e, this.index = 0 + } + e.prototype.next = function() { + if (this.index >= this.elements.length) throw new Error("array over"); + return this.elements[this.index++] + }, t.ArrayIterator = e + }).call(this) + }, function(e, t, n) { + function r(e) { + return (r = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function(e) { + return typeof e + } : function(e) { + return e && "function" == typeof Symbol && e.constructor === Symbol && e !== Symbol.prototype ? "symbol" : typeof e + })(e) + }(function() { + var e = {}.hasOwnProperty, + i = n(0).isArray, + o = (a.prototype.run = function() { + for (var e = this.callStack[this.depth], t = e.error; 0 <= this.depth && e && !this.paused;) + if ((e = t ? this.unwind(t) : e).run(), (t = e.error) instanceof Error && this.injectStackTrace(t), e.done()) { + if (e.guards.length) { + var n = e.guards.pop(); + if (n.finalizer) { + e.ip = n.finalizer, e.exitIp = n.end, e.paused = !1; + continue + } + }!e.construct || "object" !== (n = r(this.rv)) && "function" !== n && (this.rv = e.scope.get(0)), (e = this.popFrame()) && !t && (e.evalStack.push(this.rv), this.rv = void 0) + } else t = (e = this.callStack[this.depth]).error; + if (this.timedOut() && (t = new Error(this), this.injectStackTrace(t)), t) throw t + }, a.prototype.unwind = function(e) { + for (var t = this.callStack[this.depth]; t;) { + t.error = e; + var n = t.ip - 1, + r = t.guards.length; + if (r && (r = t.guards[r - 1], r.start <= n && n <= r.end)) { + if (null !== r.handler) + if (n <= r.handler) t.evalStack.push(e), t.error = null, t.ip = r.handler; + else { + if (!(r.finalizer && t.ip <= r.finalizer)) { + t = this.popFrame(); + continue + } + t.ip = r.finalizer + } + else t.ip = r.finalizer; + return t.paused = !1, t + } + t = this.popFrame() + } + throw e + }, a.prototype.injectStackTrace = function(e) { + var t, n, r, o, a, s, c, u = [], + l = 0; + for (this.depth > this.maxTraceDepth && (l = this.depth - this.maxTraceDepth), n = r = a = this.depth, s = l; a <= s ? r <= s : s <= r; n = a <= s ? ++r : --r) "" === (o = (t = this.callStack[n]).script.name) && t.fname && (o = t.fname), u.push({ + at: { + name: o, + filename: t.script.filename + }, + line: t.line, + column: t.column + }); + if (e.trace) { + for (c = e.trace; i(c[c.length - 1]);) c = c[c.length - 1]; + c.push(u) + } else e.trace = u; + return e.stack = e.toString() + }, a.prototype.pushFrame = function(e, t, n, r, i, o, a) { + if (null == o && (o = ""), null == a && (a = !1), this.checkCallStack()) return n = new d(n, e.localNames, e.localLength), n.set(0, t), a = new s(this, e, n, this.realm, o, a), i && a.evalStack.push(i), r && a.evalStack.push(r), this.callStack[++this.depth] = a + }, a.prototype.checkCallStack = function() { + return this.depth !== this.maxDepth || (this.callStack[this.depth].error = new Error("maximum call stack size exceeded"), this.pause(), !1) + }, a.prototype.popFrame = function() { + var e = this.callStack[--this.depth]; + return e && (e.paused = !1), e + }, a.prototype.pause = function() { + return this.paused = this.callStack[this.depth].paused = !0 + }, a.prototype.resume = function(e) { + if (this.timeout = null != e ? e : -1, this.paused = !1, this.callStack[this.depth].paused = !1, this.run(), !this.paused) return this.rexp + }, a.prototype.timedOut = function() { + return 0 === this.timeout + }, a.prototype.send = function(e) { + return this.callStack[this.depth].evalStack.push(e) + }, a.prototype.done = function() { + return -1 === this.depth + }, a); + + function a(e, t) { + this.realm = e, this.timeout = null != t ? t : -1, this.maxDepth = 1e3, this.maxTraceDepth = 50, this.callStack = [], this.evalStack = null, this.depth = -1, this.yielded = this.rv = void 0, this.paused = !1, this.r1 = this.r2 = this.r3 = null, this.rexp = null + } + var s = (c.prototype.run = function() { + for (var e = this.script.instructions; this.ip !== this.exitIp && !this.paused && 0 !== this.fiber.timeout;) this.fiber.timeout--, e[this.ip++].exec(this, this.evalStack, this.scope, this.realm); + 0 === this.fiber.timeout && (this.paused = this.fiber.paused = !0); + var t = this.evalStack.len(); + if (!this.paused && !this.error && 0 !== t) throw new Error("Evaluation stack has " + t + " items after execution") + }, c.prototype.done = function() { + return this.ip === this.exitIp + }, c.prototype.setLine = function(e) { + this.line = e + }, c.prototype.setColumn = function(e) { + this.column = e + }, c); + + function c(e, t, n, r, i, o) { + this.fiber = e, this.script = t, this.scope = n, this.realm = r, this.fname = i, this.construct = null != o && o, this.evalStack = new u(this.script.stackSize, this.fiber), this.ip = 0, this.exitIp = this.script.instructions.length, this.paused = !1, this.finalizer = null, this.guards = [], this.rv = void 0, this.line = this.column = -1 + } + var u = (l.prototype.push = function(e) { + if (this.idx === this.array.length) throw new Error("maximum evaluation stack size exceeded"); + return this.array[this.idx++] = e + }, l.prototype.pop = function() { + return this.array[--this.idx] + }, l.prototype.top = function() { + return this.array[this.idx - 1] + }, l.prototype.len = function() { + return this.idx + }, l.prototype.clear = function() { + return this.idx = 0 + }, l); + + function l(e, t) { + this.fiber = t, this.array = new Array(e), this.idx = 0 + } + var d = (f.prototype.get = function(e) { + return this.data[e] + }, f.prototype.set = function(e, t) { + return this.data[e] = t + }, f.prototype.name = function(t) { + var n, r = this.names; + for (n in r) + if (e.call(r, n) && r[n] === t) return parseInt(n); + return -1 + }, f); + + function f(e, t, n) { + this.parent = e, this.names = t, this.data = new Array(n) + } + var p = (h.prototype.get = function(e) { + return this.object[e] + }, h.prototype.set = function(e, t) { + return this.object[e] = t + }, h.prototype.has = function(e) { + return e in this.object + }, h); + + function h(e, t) { + this.parent = e, this.object = t + } + t.Fiber = o, t.Scope = d, t.WithScope = p + }).call(this) + }, function(e, t, n) { + n = new(n(4)); + n.eval('[" + + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/package-lock.json b/project/aitoearn-wxplat/project/aitoearn-electron/package-lock.json new file mode 100644 index 000000000..968f317bb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/package-lock.json @@ -0,0 +1,16795 @@ +{ + "name": "aiToEarn", + "version": "0.8.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aiToEarn", + "version": "0.8.0", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/icons": "^5.6.1", + "@electron-toolkit/preload": "^3.0.2", + "@electron-uikit/contextmenu": "^1.0.0", + "@electron-uikit/core": "^1.1.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@ffprobe-installer/ffprobe": "^2.1.2", + "@types/form-data": "^2.5.2", + "antd": "^5.23.1", + "antd-img-crop": "^4.24.0", + "axios": "^1.7.9", + "better-sqlite3": "^11.8.1", + "build": "^0.1.4", + "coordtransform": "^2.1.2", + "crc32": "^0.2.2", + "cropperjs": "^1.6.2", + "crypto": "^1.0.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.7", + "echarts": "^5.6.0", + "electron-log": "^5.3.0", + "electron-serve": "^2.1.1", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "events": "^3.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.2", + "fs": "^0.0.1-security", + "image-size": "^1.2.0", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "moment": "^2.30.1", + "node-cache": "^5.1.2", + "node-schedule": "^2.1.1", + "os": "^0.1.2", + "p-queue": "^8.1.0", + "path": "^0.12.7", + "qs": "^6.14.0", + "react-intersection-observer": "^9.16.0", + "react-masonry-css": "^1.0.16", + "react-router-dom": "6", + "react-sortablejs": "^6.1.4", + "reflect-metadata": "^0.2.2", + "sharp": "^0.33.5", + "sortablejs": "^1.15.6", + "typeorm": "^0.3.20", + "uninstall": "^0.0.0", + "uuid": "^11.0.5", + "vite": "^6.2.3", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "xml2js": "^0.6.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.1", + "@playwright/test": "^1.48.2", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/form-data": "^2.2.1", + "@types/lodash": "^4.17.15", + "@types/mime-types": "^2.1.4", + "@types/node-schedule": "^2.1.7", + "@types/qs": "^6.9.18", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/sortablejs": "^1.15.8", + "@types/xml2js": "^0.4.14", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "electron": "^33.2.0", + "electron-builder": "^26.0.12", + "electron-notarize": "^1.2.2", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-unused-imports": "^4.1.4", + "postcss": "^8.4.49", + "postcss-import": "^16.1.0", + "prettier": "^3.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sass": "^1.83.4", + "sass-loader": "^16.0.4", + "tailwindcss": "^3.4.15", + "typescript": "^5.4.2", + "vite": "^5.4.11", + "vite-plugin-electron": "^0.29.0", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^2.1.5" + }, + "engines": { + "node": "20.x.x" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-7.2.0.tgz", + "integrity": "sha512-bjTObSnZ9C/O8MB/B4OUtd/q9COomuJAR2SYfhxLyHvCKn4EKwCN3e+fWGMo7H5InAyV0wL17jdE9ALrdOW/6A==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.23.0", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.23.0.tgz", + "integrity": "sha512-7GAg9bD/iC9ikWatU9ym+P9ugJhi/WbsTWzcKN6T4gU0aehsprtke1UAaaSxxkjjmkJb3llet/rbUSLPgwlY4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.8", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.26.8.tgz", + "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.10", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.26.10.tgz", + "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.26.10", + "@babel/helper-compilation-targets": "^7.26.5", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.10", + "@babel/parser": "^7.26.10", + "@babel/template": "^7.26.9", + "@babel/traverse": "^7.26.10", + "@babel/types": "^7.26.10", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.27.0.tgz", + "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", + "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.26.8", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.26.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", + "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.27.0.tgz", + "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.27.0.tgz", + "integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==", + "license": "MIT", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.27.0.tgz", + "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "@babel/generator": "^7.27.0", + "@babel/parser": "^7.27.0", + "@babel/template": "^7.27.0", + "@babel/types": "^7.27.0", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", + "license": "MIT", + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@dabh/diagnostics": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", + "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "license": "MIT", + "dependencies": { + "colorspace": "1.1.x", + "enabled": "2.0.x", + "kuler": "^2.0.0" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmmirror.com/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron-toolkit/preload": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@electron-toolkit/preload/-/preload-3.0.2.tgz", + "integrity": "sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==", + "license": "MIT", + "peerDependencies": { + "electron": ">=13.0.0" + } + }, + "node_modules/@electron-uikit/contextmenu": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@electron-uikit/contextmenu/-/contextmenu-1.0.0.tgz", + "integrity": "sha512-dXYAz6zLWHvfu8C2Dwa5C8xantZSC5eXm4VLkOeBhDuVxGL4PHnCq9q5F1ljoW8/KbybLDYzqLzj1yzICWHjCA==", + "license": "MIT", + "peerDependencies": { + "@electron-uikit/core": "*", + "electron": ">=15.0.0" + }, + "peerDependenciesMeta": { + "@electron-uikit/core": { + "optional": true + } + } + }, + "node_modules/@electron-uikit/core": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@electron-uikit/core/-/core-1.1.0.tgz", + "integrity": "sha512-PH0QL5RVn7NWyA9lxihOuNZa1oDFjnX8MZOyef6x4Q/fu7yr2qVxNsu4imxBPP5p/oZjJ/HD2vswKWIiTqk4hg==", + "license": "MIT", + "peerDependencies": { + "electron": ">=15.0.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.2.18", + "resolved": "https://registry.npmmirror.com/@electron/asar/-/asar-3.2.18.tgz", + "integrity": "sha512-2XyvMe3N3Nrs8cV39IKELRHTYUWFKrmqqSY1U+GMlc0jvqjIVnoxhNd2H4JolWQncbJi1DCvb5TNxZuI2fEjWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, + "bin": { + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/@electron/node-gyp": { + "version": "10.2.0-electron.1", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "integrity": "sha512-lBSgDMQqt7QWMuIjS8zNAq5FI5o5RVBAcJUGWGI6GgoQITJt3msAkUrHp8YHj3RTVE+h70ndqMGqURjp3IfRyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^8.1.0", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.2.1", + "nopt": "^6.0.0", + "proc-log": "^2.0.1", + "semver": "^7.3.5", + "tar": "^6.2.1", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.7.2", + "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-3.7.2.tgz", + "integrity": "sha512-19/KbIR/DAxbsCkiaGMXIdPnMCJLkcf8AvGnduJtWBs/CBwiAjY1apCqOLVxrXg+rtXFCngbXhBanWjxLUt1Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@electron/windows-sign/-/windows-sign-1.2.1.tgz", + "integrity": "sha512-YfASnrhJ+ve6Q43ZiDwmpBgYgi2u0bYjeAVi2tDfN7YWAKO8X9EEOuPGtqbJpPLM6TfAHimghICjWe2eaJ8BAg==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.4.3.tgz", + "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.6.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.6.1.tgz", + "integrity": "sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@ffmpeg-installer/darwin-arm64": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz", + "integrity": "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "https://git.ffmpeg.org/gitweb/ffmpeg.git/blob_plain/HEAD:/LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/darwin-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/darwin-x64/-/darwin-x64-4.1.0.tgz", + "integrity": "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffmpeg-installer/ffmpeg": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/ffmpeg/-/ffmpeg-1.1.0.tgz", + "integrity": "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg==", + "license": "LGPL-2.1", + "optionalDependencies": { + "@ffmpeg-installer/darwin-arm64": "4.1.5", + "@ffmpeg-installer/darwin-x64": "4.1.0", + "@ffmpeg-installer/linux-arm": "4.1.3", + "@ffmpeg-installer/linux-arm64": "4.1.4", + "@ffmpeg-installer/linux-ia32": "4.1.0", + "@ffmpeg-installer/linux-x64": "4.1.0", + "@ffmpeg-installer/win32-ia32": "4.1.0", + "@ffmpeg-installer/win32-x64": "4.1.0" + } + }, + "node_modules/@ffmpeg-installer/linux-arm": { + "version": "4.1.3", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm/-/linux-arm-4.1.3.tgz", + "integrity": "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-arm64/-/linux-arm64-4.1.4.tgz", + "integrity": "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-ia32/-/linux-ia32-4.1.0.tgz", + "integrity": "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/linux-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/linux-x64/-/linux-x64-4.1.0.tgz", + "integrity": "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPLv3", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffmpeg-installer/win32-ia32": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-ia32/-/win32-ia32-4.1.0.tgz", + "integrity": "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw==", + "cpu": [ + "ia32" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffmpeg-installer/win32-x64": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/@ffmpeg-installer/win32-x64/-/win32-x64-4.1.0.tgz", + "integrity": "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg==", + "cpu": [ + "x64" + ], + "license": "GPLv3", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffprobe-installer/darwin-arm64": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/darwin-arm64/-/darwin-arm64-5.0.1.tgz", + "integrity": "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "LGPL-2.1", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/darwin-x64": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/darwin-x64/-/darwin-x64-5.1.0.tgz", + "integrity": "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@ffprobe-installer/ffprobe": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/ffprobe/-/ffprobe-2.1.2.tgz", + "integrity": "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg==", + "license": "LGPL-2.1", + "engines": { + "node": ">=14.21.2" + }, + "optionalDependencies": { + "@ffprobe-installer/darwin-arm64": "5.0.1", + "@ffprobe-installer/darwin-x64": "5.1.0", + "@ffprobe-installer/linux-arm": "5.2.0", + "@ffprobe-installer/linux-arm64": "5.2.0", + "@ffprobe-installer/linux-ia32": "5.2.0", + "@ffprobe-installer/linux-x64": "5.2.0", + "@ffprobe-installer/win32-ia32": "5.1.0", + "@ffprobe-installer/win32-x64": "5.1.0" + } + }, + "node_modules/@ffprobe-installer/linux-arm": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-arm/-/linux-arm-5.2.0.tgz", + "integrity": "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ==", + "cpu": [ + "arm" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-arm64": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-arm64/-/linux-arm64-5.2.0.tgz", + "integrity": "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ==", + "cpu": [ + "arm64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-ia32": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-ia32/-/linux-ia32-5.2.0.tgz", + "integrity": "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ==", + "cpu": [ + "ia32" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/linux-x64": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/linux-x64/-/linux-x64-5.2.0.tgz", + "integrity": "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA==", + "cpu": [ + "x64" + ], + "hasInstallScript": true, + "license": "GPL-3.0", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@ffprobe-installer/win32-ia32": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/win32-ia32/-/win32-ia32-5.1.0.tgz", + "integrity": "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ==", + "cpu": [ + "ia32" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@ffprobe-installer/win32-x64": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@ffprobe-installer/win32-x64/-/win32-x64-5.1.0.tgz", + "integrity": "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg==", + "cpu": [ + "x64" + ], + "license": "GPL-3.0", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmmirror.com/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@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/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz", + "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.1", + "@parcel/watcher-darwin-arm64": "2.5.1", + "@parcel/watcher-darwin-x64": "2.5.1", + "@parcel/watcher-freebsd-x64": "2.5.1", + "@parcel/watcher-linux-arm-glibc": "2.5.1", + "@parcel/watcher-linux-arm-musl": "2.5.1", + "@parcel/watcher-linux-arm64-glibc": "2.5.1", + "@parcel/watcher-linux-arm64-musl": "2.5.1", + "@parcel/watcher-linux-x64-glibc": "2.5.1", + "@parcel/watcher-linux-x64-musl": "2.5.1", + "@parcel/watcher-win32-arm64": "2.5.1", + "@parcel/watcher-win32-ia32": "2.5.1", + "@parcel/watcher-win32-x64": "2.5.1" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz", + "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz", + "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz", + "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz", + "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz", + "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz", + "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz", + "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz", + "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz", + "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz", + "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz", + "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz", + "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz", + "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@parcel/watcher/node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/@pkgr/core/-/core-0.2.4.tgz", + "integrity": "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@rc-component/qrcode/-/qrcode-1.0.0.tgz", + "integrity": "sha512-L+rZ4HXP2sJ1gHMGHjsg9jlYBX/SLN2D6OxP9Zn3qgtpMWtO2vUfxVFwiogHpAIqs54FnALxraUy/BCO1yRIgg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmmirror.com/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/@rc-component/trigger/-/trigger-2.2.6.tgz", + "integrity": "sha512-/9zuTnWwhQ3S3WT1T8BubuFTT46kvnXgaERR9f4BTKyn61/wpf/BvbImzYBubzJibU707FxwbKszLlHjcLiv1Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmmirror.com/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz", + "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz", + "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz", + "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz", + "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz", + "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz", + "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz", + "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz", + "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz", + "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz", + "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz", + "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz", + "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz", + "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz", + "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz", + "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz", + "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz", + "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz", + "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz", + "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz", + "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@trysound/sax": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/@trysound/sax/-/sax-0.2.0.tgz", + "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.27", + "resolved": "https://registry.npmmirror.com/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.27.tgz", + "integrity": "sha512-QiDWjihpUhriISNoBi2hJBRUUmoj/BMTYcfz+F+ZM9hHWBYABFAE6hjP/TbCZC0GWwlpa3FzvHH9RzFeRusZ7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/form-data": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@types/form-data/-/form-data-2.2.1.tgz", + "integrity": "sha512-JAMFhOaHIciYVh8fb5/83nmuO/AHwmto+Hq7a9y8FzLDcC1KCU344XDOMEmahnrTFlHjgh4L0WJFczNIX2GxnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", + "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.16", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime-types": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/@types/mime-types/-/mime-types-2.1.4.tgz", + "integrity": "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.15.2.tgz", + "integrity": "sha512-uKXqKN9beGoMdBfcaTY1ecwz6ctxuJAcUlwE55938g0ZJ8lRxwAZqRz2AJ4pzpt5dHdTPMB863UZ0ESiFUcP7A==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-schedule": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@types/node-schedule/-/node-schedule-2.1.7.tgz", + "integrity": "sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.20", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.20.tgz", + "integrity": "sha512-IPaCZN7PShZK/3t6Q87pfTkRm6oLTd4vztyoj+cbHUF1g3FfVb2tFIL79uCRKEfv16AhqDMBywP2VW3KIZUvcg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.6", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.6.tgz", + "integrity": "sha512-nf22//wEbKXusP6E9pfOCDwFdHAX4u172eaJI4YkDRQEZiorm6KfYnSC2SWLDMVWUOWPERmJnN0ujeAfTBLvrw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmmirror.com/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "license": "MIT" + }, + "node_modules/@types/svgo": { + "version": "2.6.4", + "resolved": "https://registry.npmmirror.com/@types/svgo/-/svgo-2.6.4.tgz", + "integrity": "sha512-l4cmyPEckf8moNYHdJ+4wkHvFxjyW6ulm9l4YGaOxeyBWPhBOT0gvni1InpFPdzx1dKf/2s62qGITwxNWnPQng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/triple-beam": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/@types/triple-beam/-/triple-beam-1.3.5.tgz", + "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", + "license": "MIT" + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmmirror.com/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/xml2js": { + "version": "0.4.14", + "resolved": "https://registry.npmmirror.com/@types/xml2js/-/xml2js-0.4.14.tgz", + "integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmmirror.com/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.0.tgz", + "integrity": "sha512-evaQJZ/J/S4wisevDvC1KFZkPzRetH8kYZbkgcTRyql3mcKsf+ZFDV1BVWUGTCAW5pQHoqn5gK5b8kn7ou9aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/type-utils": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.31.0.tgz", + "integrity": "sha512-67kYYShjBR0jNI5vsf/c3WG4u+zDnCTHTPqVMQguffaWWFs7artgwKmfwdifl+r6XyM5LYLas/dInj2T0SgJyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.31.0.tgz", + "integrity": "sha512-knO8UyF78Nt8O/B64i7TlGXod69ko7z6vJD9uhSlm0qkAbGeRUSudcm0+K/4CrRjrpiHfBCjMWlc08Vav1xwcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.31.0.tgz", + "integrity": "sha512-DJ1N1GdjI7IS7uRlzJuEDCgDQix3ZVYVtgeWEyhyn4iaoitpMBX6Ndd488mXSx0xah/cONAkEaYyylDyAeHMHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.31.0", + "@typescript-eslint/utils": "8.31.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.31.0.tgz", + "integrity": "sha512-Ch8oSjVyYyJxPQk8pMiP2FFGYatqXQfQIaMp+TpuuLlDachRWpUAeEu1u9B/v/8LToehUIWyiKcA/w5hUFRKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.0.tgz", + "integrity": "sha512-xLmgn4Yl46xi6aDSZ9KkyfhhtnYI15/CvHbpOy/eR5NWhK/BK8wc709KKwhAR0m4ZKRP7h07bm4BWUYOCuRpQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/visitor-keys": "8.31.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.0.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.31.0.tgz", + "integrity": "sha512-qi6uPLt9cjTFxAb1zGNgTob4x9ur7xC6mHQJ8GwEzGMGE9tYniublmJaowOJ9V2jUzxrltTPfdG2nKlWsq0+Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.31.0", + "@typescript-eslint/types": "8.31.0", + "@typescript-eslint/typescript-estree": "8.31.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.31.0", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.0.tgz", + "integrity": "sha512-QcGHmlRHWOl93o64ZUMNewCdwKGU6WItOU52H0djgNmn1EOrhVudrDzXz4OycCRSCPwFCDrE2iIt5vmuUdHxuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.31.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.10", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.10.tgz", + "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/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": "3.17.0", + "resolved": "https://registry.npmmirror.com/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/antd": { + "version": "5.24.8", + "resolved": "https://registry.npmmirror.com/antd/-/antd-5.24.8.tgz", + "integrity": "sha512-vJcW81WSRq+ymBKTiA3NE+FddmiqTAKxdWVRZU+HnLLrRrIz896svcUxXFPa7M4mH9HqyeJ5JPOHsne4sQAC1A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.2.6", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.33.1", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.2.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.0", + "rc-image": "~7.11.1", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.6", + "rc-slider": "~11.1.8", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.50.4", + "rc-tabs": "~15.6.0", + "rc-textarea": "~1.10.0", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.8.1", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd-img-crop": { + "version": "4.24.0", + "resolved": "https://registry.npmmirror.com/antd-img-crop/-/antd-img-crop-4.24.0.tgz", + "integrity": "sha512-RqY/XqvmUnHlj7oLV2kN/ytdZdHUFAZyM3TN+QlTlLrze1Q74isAePgG+QTkLAbrkR9L/IgVRpjsuH/nevpU7Q==", + "license": "MIT", + "dependencies": { + "react-easy-crop": "^5.2.0", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "antd": ">=4.0.0", + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.12", + "resolved": "https://registry.npmmirror.com/app-builder-bin/-/app-builder-bin-5.0.0-alpha.12.tgz", + "integrity": "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "26.0.12", + "resolved": "https://registry.npmmirror.com/app-builder-lib/-/app-builder-lib-26.0.12.tgz", + "integrity": "sha512-+/CEPH1fVKf6HowBUs6LcAIoRcjeqgvAeoSE+cl7Y7LndyQ9ViGPYibNk7wmhMHzNgHIuIbw4nWADPO+4mjgWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.2.18", + "@electron/fuses": "^1.8.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.7.0", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "builder-util": "26.0.11", + "builder-util-runtime": "9.3.1", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.0.11", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "plist": "3.1.0", + "resedit": "^1.7.0", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.0.12", + "electron-builder-squirrel-windows": "26.0.12" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/rebuild": { + "version": "3.7.0", + "resolved": "https://registry.npmmirror.com/@electron/rebuild/-/rebuild-3.7.0.tgz", + "integrity": "sha512-VW++CNSlZwMYP7MyXEbrKjpzEwhB5kDNbzGtiPEjwYysqyTCF+YbNJ210Dj3AjWsGSV4iEEwNkmJN9yGZmVvmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/node-gyp": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/app-builder-lib/node_modules/minimatch": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/app-root-path": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/app-root-path/-/app-root-path-3.1.0.tgz", + "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmmirror.com/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmmirror.com/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true, + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/atomically": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/atomically/-/atomically-2.0.3.tgz", + "integrity": "sha512-kU6FmrwZ3Lx7/7y3hPS5QnbJfaohcIul5fGqf7ok+4KklIEk9tJ0C2IQPdacSbVUWv6zVHXEBWoWd6NrVMT7Cw==", + "dependencies": { + "stubborn-fs": "^1.2.5", + "when-exit": "^2.1.1" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/better-sqlite3": { + "version": "11.9.1", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-11.9.1.tgz", + "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "optional": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmmirror.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/build": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/build/-/build-0.1.4.tgz", + "integrity": "sha512-KwbDJ/zrsU8KZRRMfoURG14cKIAStUlS8D5jBDvtrZbwO5FEkYqc3oB8HIhRiyD64A48w1lc+sOmQ+mmBw5U/Q==", + "dependencies": { + "cssmin": "0.3.x", + "jsmin": "1.x", + "jxLoader": "*", + "moo-server": "*", + "promised-io": "*", + "timespan": "2.x", + "uglify-js": "1.x", + "walker": "1.x", + "winston": "*", + "wrench": "1.3.x" + }, + "engines": { + "node": ">v0.4.12" + } + }, + "node_modules/builder-util": { + "version": "26.0.11", + "resolved": "https://registry.npmmirror.com/builder-util/-/builder-util-26.0.11.tgz", + "integrity": "sha512-xNjXfsldUEe153h1DraD0XvDOpqGR0L5eKFkdReB7eFW5HqysDZFfly4rckda6y9dF39N3pkPlOblcfHKGw+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.12", + "builder-util-runtime": "9.3.1", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.3.1", + "resolved": "https://registry.npmmirror.com/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz", + "integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmmirror.com/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cache-base/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmmirror.com/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001715", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/class-utils/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/class-utils/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmmirror.com/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha512-lNkKvzEeMBBjUGHZ+q6z9pSJla0KWAQPvtzhEV9+iGyQYG+pBpl7xKDhxoNSOZH2hhv0v5k0y2yAM4o4SjoSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorspace": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/colorspace/-/colorspace-1.1.4.tgz", + "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "license": "MIT", + "dependencies": { + "color": "^3.1.3", + "text-hex": "1.0.x" + } + }, + "node_modules/colorspace/node_modules/color": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/color/-/color-3.2.1.tgz", + "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.3", + "color-string": "^1.6.0" + } + }, + "node_modules/colorspace/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/colorspace/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/conf": { + "version": "13.1.0", + "resolved": "https://registry.npmmirror.com/conf/-/conf-13.1.0.tgz", + "integrity": "sha512-Bi6v586cy1CoTFViVO4lGTtx780lfF96fUmS1lSX6wpZf6330NvHUu6fReVuDP1de8Mg0nkZb01c8tAQdz1o3w==", + "license": "MIT", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "atomically": "^2.0.3", + "debounce-fn": "^6.0.0", + "dot-prop": "^9.0.0", + "env-paths": "^3.0.0", + "json-schema-typed": "^8.0.1", + "semver": "^7.6.3", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/conf/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/conf/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmmirror.com/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/coordtransform": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/coordtransform/-/coordtransform-2.1.2.tgz", + "integrity": "sha512-0xLJApBlrUP+clyLJWIaqg4GXE5JTbAJb5d/CDMqebIksAMMze8eAyO6YfHEIxWJ+c42mXoMHBzWTeUrG7RFhw==", + "license": "MIT" + }, + "node_modules/copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha512-XgZ0pFcakEUlbwQEVNg3+QAis1FyTL3Qel9FYy8pSkQqoG3PNoT0bOCQtOXcOkur21r2Eq2kI+IE+gsmAEVlYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmmirror.com/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc32": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/crc32/-/crc32-0.2.2.tgz", + "integrity": "sha512-PFZEGbDUeoNbL2GHIEpJRQGheXReDody/9axKTxhXtQqIL443wnNigtVZO9iuCIMPApKZRv7k2xr8euXHqNxQQ==", + "bin": { + "crc32": "bin/runner.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/cropperjs": { + "version": "1.6.2", + "resolved": "https://registry.npmmirror.com/cropperjs/-/cropperjs-1.6.2.tgz", + "integrity": "sha512-nhymn9GdnV3CqiEHJVai54TULFAE3VshJTXSqSJKa8yXAKyBKDWdhHarnlIPrshJ0WMFTGuFvG02YjLXfPiuOA==", + "license": "MIT" + }, + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/css-select/-/css-select-4.3.0.tgz", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select/node_modules/dom-serializer": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-1.4.1.tgz", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/css-select/node_modules/domhandler": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-4.3.1.tgz", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/css-select/node_modules/domutils": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-2.8.0.tgz", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/css-select/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.1.0.tgz", + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssmin": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/cssmin/-/cssmin-0.3.2.tgz", + "integrity": "sha512-bynxGIAJ8ybrnFobjsQotIjA8HFDDgPwbeUWNXXXfR+B4f9kkxdcUyagJoQCSUOfMV+ZZ6bMn8bvbozlCzUGwQ==", + "bin": { + "cssmin": "bin/cssmin" + } + }, + "node_modules/csso": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/csso/-/csso-4.2.0.tgz", + "integrity": "sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, + "node_modules/debounce-fn": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/debounce-fn/-/debounce-fn-6.0.0.tgz", + "integrity": "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==", + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT", + "optional": true + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dmg-builder": { + "version": "26.0.12", + "resolved": "https://registry.npmmirror.com/dmg-builder/-/dmg-builder-26.0.12.tgz", + "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.0.12", + "builder-util": "26.0.11", + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmmirror.com/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmmirror.com/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmmirror.com/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "26.0.12", + "resolved": "https://registry.npmmirror.com/electron-builder/-/electron-builder-26.0.12.tgz", + "integrity": "sha512-cD1kz5g2sgPTMFHjLxfMjUK5JABq3//J4jPswi93tOPFz6btzXYtK5NrDt717NRbukCUDOrrvmYVOWERlqoiXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.0.12", + "builder-util": "26.0.11", + "builder-util-runtime": "9.3.1", + "chalk": "^4.1.2", + "dmg-builder": "26.0.12", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "26.0.12", + "resolved": "https://registry.npmmirror.com/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.0.12.tgz", + "integrity": "sha512-kpwXM7c/ayRUbYVErQbsZ0nQZX4aLHQrPEG9C4h9vuJCXylwFH8a7Jgi2VpKIObzCXO7LKHiCw4KdioFLFOgqA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.0.12", + "builder-util": "26.0.11", + "electron-winstaller": "5.4.0" + } + }, + "node_modules/electron-log": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/electron-log/-/electron-log-5.3.4.tgz", + "integrity": "sha512-QLj0EbsA5R5Yy4vjGlLe7m8hPNZ/Enp7c7a2WH7RUPr0hIOp0vDaC+6bJM0th6+uZKiZGGH5a2aKzvYp3eYwDQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/electron-notarize": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/electron-notarize/-/electron-notarize-1.2.2.tgz", + "integrity": "sha512-ZStVWYcWI7g87/PgjPJSIIhwQXOaw4/XeXU+pWqMMktSLHaGMLHdyPPN7Cmao7+Cr7fYufA16npdtMndYciHNw==", + "deprecated": "Please use @electron/notarize moving forward. There is no API change, just a package name change", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-publish": { + "version": "26.0.11", + "resolved": "https://registry.npmmirror.com/electron-publish/-/electron-publish-26.0.11.tgz", + "integrity": "sha512-a8QRH0rAPIWH9WyyS5LbNvW9Ark6qe63/LqDB7vu2JXYpi0Gma5Q60Dh4tmTqhOBQt0xsrzD8qE7C+D7j+B24A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "26.0.11", + "builder-util-runtime": "9.3.1", + "chalk": "^4.1.2", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-serve": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/electron-serve/-/electron-serve-2.1.1.tgz", + "integrity": "sha512-SJdA7bfZuPODK9BkXTZ8EPsQlzg6yNSA0H6811DQvqzyDhPP8icIACA0bIA9aWeT+gmDB7V4eMg/wZhR+ijk6w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-store": { + "version": "10.0.1", + "resolved": "https://registry.npmmirror.com/electron-store/-/electron-store-10.0.1.tgz", + "integrity": "sha512-Ok0bF13WWdTzZi9rCtPN8wUfwx+yDMmV6PAnCMqjNRKEXHmklW/rV+6DofV/Vf5qoAh+Bl9Bj7dQ+0W+IL2psg==", + "license": "MIT", + "dependencies": { + "conf": "^13.0.0", + "type-fest": "^4.20.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.143", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.143.tgz", + "integrity": "sha512-QqklJMOFBMqe46k8iIOwA9l2hz57V2OKMmP5eSWcUvwx+mASAsbU+wkF1pHjn9ZVSBPrsYWr4/W/95y5SwYg2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.6.2", + "resolved": "https://registry.npmmirror.com/electron-updater/-/electron-updater-6.6.2.tgz", + "integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.3.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "^7.6.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmmirror.com/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" + } + }, + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/electron/node_modules/@types/node": { + "version": "20.17.31", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.17.31.tgz", + "integrity": "sha512-quODOCNXQAbNf1Q7V+fI8WyErOCh0D5Yd31vHnKu4GkSztGQ7rlltAaqXhHhLl33tlVyUXs2386MkANSwgDn6A==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enabled": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/enabled/-/enabled-2.0.0.tgz", + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-ex/node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.23.9", + "resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.23.9.tgz", + "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.0", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-regex": "^1.2.1", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.0", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.3", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.3", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/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/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT", + "optional": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmmirror.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.6", + "resolved": "https://registry.npmmirror.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz", + "integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.11.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmmirror.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.1.4", + "resolved": "https://registry.npmmirror.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.1.4.tgz", + "integrity": "sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmmirror.com/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/expand-brackets/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-brackets/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/expand-brackets/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.2.1.tgz", + "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/exponential-backoff/-/exponential-backoff-3.1.2.tgz", + "integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fecha": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/fecha/-/fecha-4.2.3.tgz", + "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", + "license": "MIT" + }, + "node_modules/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/fn.name": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/fn.name/-/fn.name-1.1.0.tgz", + "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha512-GMBAbW9antB8iZRHLoGw0b3HANt57diZYFO/HL1JGIC1MjKrdmhxvrJbupnVvpys0zsz7yBApXdQyfepKly2kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-cache": "^0.2.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmmirror.com/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "license": "ISC" + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "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.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmmirror.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/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/got": { + "version": "11.8.6", + "resolved": "https://registry.npmmirror.com/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha512-IBXk4GTsLYdQ7Rvt+GRBrFSVEkmuOUy4re0Xjd9kJSUQpnTrWR4/y9RpfexN9vkAPMFuQoeWKwqzPozRTlasGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-value/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha512-ODYZC64uqzmtfGMEAX/FvZiRyWLpAC3vYnNunURUnkGVTS+mI0smVsWaPydRBsE3g+ok7h960jChO8mFcWlHaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/is-number/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-values/node_modules/kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha512-24XsCxmEbRwEDbz/qz3stgin8TTzZ1ESR56OMCN0ujYg+vRutNSiOj9bHH9u85DKgXguraugV5sFuvbD4FW/hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/htmlparser2": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-3.10.1.tgz", + "integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^1.3.1", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "inherits": "^2.0.1", + "readable-stream": "^3.1.1" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmmirror.com/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immutable": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.1.tgz", + "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-9.0.5.tgz", + "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "1.1.0", + "sprintf-js": "^1.1.3" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-accessor-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", + "integrity": "sha512-YBUanLI8Yoihw923YeFUS5fs0fF2f5TSFTNiYAAzhhDscDa3lEqYuz1pDOEP5KvX94I9ey3vsqjJcLVFVU+3QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmmirror.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmmirror.com/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-descriptor": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-data-descriptor/-/is-data-descriptor-1.0.1.tgz", + "integrity": "sha512-bc4NlCDiCr28U4aEsQ3Qs2491gVq4V8G7MQyws968ImqjKuYtTJXrl7Vq7jsN7Ly/C3xj5KWFrY7sHNeDkAzXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-descriptor": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-1.0.3.tgz", + "integrity": "sha512-JCNNGbwWZEVaSPtS45mdtrneRWJFp07LLmykxeFV5F6oBvNF8vHSfJuJgoT472pSfk+Mf8VnlrspaFBHWM8JAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmmirror.com/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.4.tgz", + "integrity": "sha512-YKBKVkKhty7s8rxddb40oOkuP0NbaeXrQvLin6QMHL7Ypiy2RW9LwOVrVgZRyOrhQlayMd9t+D8yDy8MKFTSDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isobject/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmmirror.com/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-base64": { + "version": "2.6.4", + "resolved": "https://registry.npmmirror.com/js-base64/-/js-base64-2.6.4.tgz", + "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/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/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsmin": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/jsmin/-/jsmin-1.0.1.tgz", + "integrity": "sha512-OPuL5X/bFKgVdMvEIX3hnpx3jbVpFCrEM8pKPXjFkZUqg521r41ijdyTz7vACOhW6o1neVlcLyd+wkbK5fNHRg==", + "license": "Doug Crockford's license that allows this module to be used for Good but not for Evil", + "bin": { + "jsmin": "bin/jsmin" + }, + "engines": { + "node": ">=0.1.93" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.1", + "resolved": "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.1.tgz", + "integrity": "sha512-XQmWYj2Sm4kn4WeTYvmpKEbyPsL7nBsb647c7pMe6l02/yx2+Jfc4dT6UZkEXnIUb5LhD55r2HPsJ1milQ4rDg==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jxLoader": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/jxLoader/-/jxLoader-0.1.1.tgz", + "integrity": "sha512-ClEvAj3K68y8uKhub3RgTmcRPo5DfIWvtxqrKQdDPyZ1UVHIIKvVvjrAsJFSVL5wjv0rt5iH9SMCZ0XRKNzeUA==", + "dependencies": { + "js-yaml": "0.3.x", + "moo-server": "1.3.x", + "promised-io": "*", + "walker": "1.x" + }, + "engines": { + "node": ">v0.4.10" + } + }, + "node_modules/jxLoader/node_modules/js-yaml": { + "version": "0.3.7", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-0.3.7.tgz", + "integrity": "sha512-/7PsVDNP2tVe2Z1cF9kTEkjamIwz4aooDpRKmN1+g/9eePCgcxsv4QDvEbxO0EH+gdDD7MLyDoR6BASo3hH51g==", + "license": "MIT", + "engines": { + "node": "> 0.4.11" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kuler": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/kuler/-/kuler-2.0.0.tgz", + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loader-utils": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-1.4.2.tgz", + "integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^1.0.1" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/loader-utils/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logform": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/logform/-/logform-2.7.0.tgz", + "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", + "license": "MIT", + "dependencies": { + "@colors/colors": "1.6.0", + "@types/triple-beam": "^1.3.2", + "fecha": "^4.2.0", + "ms": "^2.1.1", + "safe-stable-stringify": "^2.3.1", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/long-timeout": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/long-timeout/-/long-timeout-0.1.1.tgz", + "integrity": "sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/luxon": { + "version": "3.6.1", + "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.6.1.tgz", + "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmmirror.com/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha512-4y7uGv8bd2WdM9vpQsiQNo41Ln1NvhvDRuVt0k2JZQ+ezN2uaQes7lZeZ+QQUHOLQAtDaBJ+7wCbi+ab/KFs+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-visit": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-options": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/merge-options/-/merge-options-1.0.1.tgz", + "integrity": "sha512-iuPV41VWKWBIOpBsjoxjDZw8/GbSfZ2mk7N1453bwMrfzdrIk7EzBd+8UVR6rkw67th7xnk9Dytl3J+lHPdxvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-obj": "^1.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-deep/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmmirror.com/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moo-server": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/moo-server/-/moo-server-1.3.0.tgz", + "integrity": "sha512-9A8/eor2DXwpv1+a4pZAAydqLFVrWoKoO1fzdzqLUhYVXAO1Kgd1FR2gFZi7YdHzF0s4W8cDNwCfKJQrvLqxDw==", + "engines": { + "node": ">v0.4.10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmmirror.com/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nanomatch/node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-abi": { + "version": "3.74.0", + "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.74.0.tgz", + "integrity": "sha512-c5XK0MjkGBrQPGYG24GBADZud0NCbznxNx0ZkS+ebUTrmV1qTDxPxSL8zEAPURXSbLRWVexxmP4986BziahL5w==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-cache": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/node-cache/-/node-cache-5.1.2.tgz", + "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", + "license": "MIT", + "dependencies": { + "clone": "2.x" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-schedule": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/node-schedule/-/node-schedule-2.1.1.tgz", + "integrity": "sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==", + "license": "MIT", + "dependencies": { + "cron-parser": "^4.2.0", + "long-timeout": "0.1.1", + "sorted-array-functions": "^1.3.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmmirror.com/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==", + "license": "BSD-3-Clause" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha512-79LYn6VAb63zgtmAteVOWo9Vdj71ZVBy3Pbse+VqxDpEP83XuujMrGqHIwAXJ5I/aM0zU7dIyIAhifVTPrNItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-copy/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-copy/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/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/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha512-GBaMwwAVK9qbQN3Scdo0OyvgPW7l3lnaVMj84uTOZlswkX0KpF6fyDBJhtTthf7pymztoN36/KEr1DyhF96zEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-visit/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmmirror.com/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmmirror.com/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.pick/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/one-time": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/one-time/-/one-time-1.0.0.tgz", + "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", + "license": "MIT", + "dependencies": { + "fn.name": "1.x.x" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/os/-/os-0.1.2.tgz", + "integrity": "sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==", + "license": "MIT" + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "8.1.0", + "resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-8.1.0.tgz", + "integrity": "sha512-mxLDbbGIBEXTJL0zEx8JIylaj3xQ7Z/7eEVjcF9fJX4DBiH9oqe+oahYnlKKxm0Ci9TlWTyhSHgygxMxjIB2jw==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^6.1.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-6.1.4.tgz", + "integrity": "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path": { + "version": "0.12.7", + "resolved": "https://registry.npmmirror.com/path/-/path-0.12.7.tgz", + "integrity": "sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==", + "license": "MIT", + "dependencies": { + "process": "^0.11.1", + "util": "^0.10.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-0.2.0.tgz", + "integrity": "sha512-sTitTPYnn23esFR3RlqYBWn4c45WGeLcsKzQiUpXJAyfcWkolvlYpV8FLo7JishK946oQwMFUCHXQ9AjGPKExw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmmirror.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "16.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-16.1.0.tgz", + "integrity": "sha512-7hsAZ4xGXl4MW+OKEWCnF6T5jqBw80/EE9aXg1r2yyn1RsVEU8EtKXbijEODa+rg7iih4bKf7vlvTGYR4CnPNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "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/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-prefix-selector": { + "version": "1.16.1", + "resolved": "https://registry.npmmirror.com/postcss-prefix-selector/-/postcss-prefix-selector-1.16.1.tgz", + "integrity": "sha512-Umxu+FvKMwlY6TyDzGFoSUnzW+NOfMBLyC1tAkIjgX+Z/qGspJeRjVC903D7mx7TuBpJlwti2ibXtWuA7fKMeQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "postcss": ">4 <9" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/posthtml": { + "version": "0.9.2", + "resolved": "https://registry.npmmirror.com/posthtml/-/posthtml-0.9.2.tgz", + "integrity": "sha512-spBB5sgC4cv2YcW03f/IAUN1pgDJWNWD8FzkyY4mArLUMJW+KlQhlmUdKAHQuPfb00Jl5xIfImeOsf6YL8QK7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "posthtml-parser": "^0.2.0", + "posthtml-render": "^1.0.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/posthtml-parser": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/posthtml-parser/-/posthtml-parser-0.2.1.tgz", + "integrity": "sha512-nPC53YMqJnc/+1x4fRYFfm81KV2V+G9NZY+hTohpYg64Ay7NemWWcV4UWuy/SgMupqQ3kJ88M/iRfZmSnxT+pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^3.8.3", + "isobject": "^2.1.0" + } + }, + "node_modules/posthtml-rename-id": { + "version": "1.0.12", + "resolved": "https://registry.npmmirror.com/posthtml-rename-id/-/posthtml-rename-id-1.0.12.tgz", + "integrity": "sha512-UKXf9OF/no8WZo9edRzvuMenb6AD5hDLzIepJW+a4oJT+T/Lx7vfMYWT4aWlGNQh0WMhnUx1ipN9OkZ9q+ddEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "1.0.5" + } + }, + "node_modules/posthtml-rename-id/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/posthtml-render": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/posthtml-render/-/posthtml-render-1.4.0.tgz", + "integrity": "sha512-W1779iVHGfq0Fvh2PROhCe2QhB8mEErgqzo1wpIt36tCgChafP+hbXIhLDOM8ePJrZcFs0vkNEtdibEWVqChqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/posthtml-svg-mode": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/posthtml-svg-mode/-/posthtml-svg-mode-1.0.3.tgz", + "integrity": "sha512-hEqw9NHZ9YgJ2/0G7CECOeuLQKZi8HjWLkBaSVtOWjygQ9ZD8P7tqeowYs7WrFdKsWEKG7o+IlsPY8jrr0CJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "merge-options": "1.0.1", + "posthtml": "^0.9.2", + "posthtml-parser": "^0.2.1", + "posthtml-render": "^1.0.6" + } + }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmmirror.com/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmmirror.com/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/proc-log": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/proc-log/-/proc-log-2.0.1.tgz", + "integrity": "sha512-Kcmo2FhfDTXdcbfDH76N7uBYHINxc/8GW7UAVuVP9I+Va3uHSerrnKV6dLooga/gh7GlgzuCCr/eoldnL1muGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/promised-io": { + "version": "0.3.6", + "resolved": "https://registry.npmmirror.com/promised-io/-/promised-io-0.3.6.tgz", + "integrity": "sha512-bNwZusuNIW4m0SPR8jooSyndD35ggirHlxVl/UhIaZD/F0OBv9ebfc6tNmbpZts3QXHggkjIBH8lvtnzhtcz0A==" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmmirror.com/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmmirror.com/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/query-string": { + "version": "4.3.4", + "resolved": "https://registry.npmmirror.com/query-string/-/query-string-4.3.4.tgz", + "integrity": "sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "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/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc-cascader": { + "version": "3.33.1", + "resolved": "https://registry.npmmirror.com/rc-cascader/-/rc-cascader-3.33.1.tgz", + "integrity": "sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmmirror.com/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmmirror.com/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/rc-drawer/-/rc-drawer-7.2.0.tgz", + "integrity": "sha512-9lOQ7kBekEJRdEpScHvtmEtXnAsy+NGDXiRWc2ZVC7QXAazNVbeT4EraQKYwCME8BJLa8Bxqxvs5swwyOepRwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/rc-field-form/-/rc-field-form-2.7.0.tgz", + "integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.11.1", + "resolved": "https://registry.npmmirror.com/rc-image/-/rc-image-7.11.1.tgz", + "integrity": "sha512-XuoWx4KUXg7hNy5mRTy1i8c8p3K8boWg6UajbHpDXS5AlRVucNfTi5YxTtPBTBzegxAZpvuLfh3emXFt6ybUdA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmmirror.com/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmmirror.com/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmmirror.com/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmmirror.com/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmmirror.com/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rc-overflow/-/rc-overflow-1.4.1.tgz", + "integrity": "sha512-3MoPQQPV1uKyOMVNd6SZfONi+f3st0r8PksexIdBTeIYbMX0Jr+k7pHEDvsXtR4BpCv90/Pv2MovVNhktKrwvw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmmirror.com/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmmirror.com/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmmirror.com/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.6", + "resolved": "https://registry.npmmirror.com/rc-select/-/rc-select-14.16.6.tgz", + "integrity": "sha512-YPMtRPqfZWOm2XGTbx5/YVr1HT0vn//8QS77At0Gjb3Lv+Lbut0IORJPKLWu1hQ3u4GsA0SrDzs7nI8JG7Zmyg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.8", + "resolved": "https://registry.npmmirror.com/rc-slider/-/rc-slider-11.1.8.tgz", + "integrity": "sha512-2gg/72YFSpKP+Ja5AjC5DPL1YnV8DEITDQrcc1eASrUYjl0esptaBVJBh5nLTXCCp15eD8EuGjwezVGSHhs9tQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.50.4", + "resolved": "https://registry.npmmirror.com/rc-table/-/rc-table-7.50.4.tgz", + "integrity": "sha512-Y+YuncnQqoS5e7yHvfvlv8BmCvwDYDX/2VixTBEhkMDk9itS9aBINp4nhzXFKiBP/frG4w0pS9d9Rgisl0T1Bw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.6.0", + "resolved": "https://registry.npmmirror.com/rc-tabs/-/rc-tabs-15.6.0.tgz", + "integrity": "sha512-SQ99Yjc9ewrJCUwoWPKq0FeGL2znWsqPhfcZgsHz1R7bkA2rMNe7CPgOiJkwppdJ98wkLhzs9vPrv21QOE1RyQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/rc-textarea/-/rc-textarea-1.10.0.tgz", + "integrity": "sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmmirror.com/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmmirror.com/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.8.1", + "resolved": "https://registry.npmmirror.com/rc-upload/-/rc-upload-4.8.1.tgz", + "integrity": "sha512-toEAhwl4hjLAI1u8/CgKWt30BR06ulPa4iGQSMvSXoHzO88gPCslxqV/mnn4gJU7PDoltGIC9Eh+wkeudqgHyw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmmirror.com/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/rc-virtual-list": { + "version": "3.18.5", + "resolved": "https://registry.npmmirror.com/rc-virtual-list/-/rc-virtual-list-3.18.5.tgz", + "integrity": "sha512-1FuxVSxhzTj3y8k5xMPbhXCB0t2TOiI3Tq+qE2Bu+GGV7f+ECVuQl4OUg6lZ2qT5fordTW7CBpr9czdzXCI7Pg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-easy-crop": { + "version": "5.4.1", + "resolved": "https://registry.npmmirror.com/react-easy-crop/-/react-easy-crop-5.4.1.tgz", + "integrity": "sha512-Djtsi7bWO75vkKYkVxNRrJWY69pXLahIAkUN0mmt9cXNnaq2tpG59ctSY6P7ipJgBc7COJDRMRuwb2lYwtACNQ==", + "license": "MIT", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "^2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-intersection-observer": { + "version": "9.16.0", + "resolved": "https://registry.npmmirror.com/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", + "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "license": "MIT", + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-masonry-css": { + "version": "1.0.16", + "resolved": "https://registry.npmmirror.com/react-masonry-css/-/react-masonry-css-1.0.16.tgz", + "integrity": "sha512-KSW0hR2VQmltt/qAa3eXOctQDyOu7+ZBevtKgpNDSzT7k5LA/0XntNa9z9HKCdz3QlxmJHglTZ18e4sX4V8zZQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.0", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-6.30.0.tgz", + "integrity": "sha512-D3X8FyH9nBcTSHGdEKurK7r8OYE1kKFn3d/CF+CoxbSHkxU7o37+Uh7eAHRXr6k2tSExXYO++07PeXJtA/dEhQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.0", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-6.30.0.tgz", + "integrity": "sha512-x30B78HV5tFk8ex0ITwzC9TTZMua4jGyA9IUlH1JLQYQTFyxr/ZxwOJq7evg1JX1qGVUcvhsmQSKdPncQrjTgA==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-sortablejs": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/react-sortablejs/-/react-sortablejs-6.1.4.tgz", + "integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==", + "license": "MIT", + "dependencies": { + "classnames": "2.3.1", + "tiny-invariant": "1.2.0" + }, + "peerDependencies": { + "@types/sortablejs": "1", + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "sortablejs": "1" + } + }, + "node_modules/react-sortablejs/node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==", + "license": "MIT" + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "license": "MIT" + }, + "node_modules/regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex-not/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regex-not/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmmirror.com/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==", + "deprecated": "https://github.com/lydell/resolve-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmmirror.com/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmmirror.com/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/rollup": { + "version": "4.40.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.40.0.tgz", + "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.0", + "@rollup/rollup-android-arm64": "4.40.0", + "@rollup/rollup-darwin-arm64": "4.40.0", + "@rollup/rollup-darwin-x64": "4.40.0", + "@rollup/rollup-freebsd-arm64": "4.40.0", + "@rollup/rollup-freebsd-x64": "4.40.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.0", + "@rollup/rollup-linux-arm-musleabihf": "4.40.0", + "@rollup/rollup-linux-arm64-gnu": "4.40.0", + "@rollup/rollup-linux-arm64-musl": "4.40.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-gnu": "4.40.0", + "@rollup/rollup-linux-riscv64-musl": "4.40.0", + "@rollup/rollup-linux-s390x-gnu": "4.40.0", + "@rollup/rollup-linux-x64-gnu": "4.40.0", + "@rollup/rollup-linux-x64-musl": "4.40.0", + "@rollup/rollup-win32-arm64-msvc": "4.40.0", + "@rollup/rollup-win32-ia32-msvc": "4.40.0", + "@rollup/rollup-win32-x64-msvc": "4.40.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "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", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/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/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/sanitize-filename/-/sanitize-filename-1.6.3.tgz", + "integrity": "sha512-y/52Mcy7aw3gRm7IrcGDFx/bCk4AhRh2eI9luHOQM86nZsqwiRkkq2GekHXBBD+SmPidc8i2PqtYZl+pWJ8Oeg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sass": { + "version": "1.87.0", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.87.0.tgz", + "integrity": "sha512-d0NoFH4v6SjEK7BoX810Jsrhj7IQSYHAHLi/iSpgqKc7LaIDshFRlSg5LOymf9FqQhxEHs2W5ZQXlvy0KD45Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.0.2", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.5", + "resolved": "https://registry.npmmirror.com/sass-loader/-/sass-loader-16.0.5.tgz", + "integrity": "sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.4.1.tgz", + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT", + "optional": true + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "optional": true, + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/serialize-error/node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sha.js": { + "version": "2.4.11", + "resolved": "https://registry.npmmirror.com/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + }, + "bin": { + "sha.js": "bin.js" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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.npmmirror.com/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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmmirror.com/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-node/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon-util/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/snapdragon/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/snapdragon/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/snapdragon/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/snapdragon/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/socks": { + "version": "2.8.4", + "resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^9.0.5", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmmirror.com/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, + "node_modules/sorted-array-functions": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/sorted-array-functions/-/sorted-array-functions-1.3.0.tgz", + "integrity": "sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-resolve": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz", + "integrity": "sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==", + "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated", + "dev": true, + "license": "MIT", + "dependencies": { + "atob": "^2.1.2", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-url": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/source-map-url/-/source-map-url-0.4.1.tgz", + "integrity": "sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==", + "deprecated": "See https://github.com/lydell/source-map-url#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/sql-highlight": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/sql-highlight/-/sql-highlight-6.0.0.tgz", + "integrity": "sha512-+fLpbAbWkQ+d0JEchJT/NrRRXbYRNbG15gFpANx73EwxQB1PRjj+k/OI0GTU0J63g8ikGkJECQp9z8XEJZvPRw==", + "funding": [ + "https://github.com/scriptcoded/sql-highlight?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/scriptcoded" + } + ], + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stable": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/stable/-/stable-0.1.8.tgz", + "integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==", + "deprecated": "Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility", + "dev": true, + "license": "MIT" + }, + "node_modules/stack-trace": { + "version": "0.0.10", + "resolved": "https://registry.npmmirror.com/stack-trace/-/stack-trace-0.0.10.tgz", + "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha512-72E9+uLc27Mt718pMHt9VMNiAL4LMsmDbBva8mxWUCkT07fSzEGMYUCk0XWY6lp0j6RBAG4cJ3mWuZv2OE3s0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/static-extend/node_modules/is-descriptor": { + "version": "0.1.7", + "resolved": "https://registry.npmmirror.com/is-descriptor/-/is-descriptor-0.1.7.tgz", + "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-accessor-descriptor": "^1.0.1", + "is-data-descriptor": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/std-env": { + "version": "3.9.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.9.0.tgz", + "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-uri-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", + "integrity": "sha512-R3f198pcvnB+5IpnBlRkphuE9n46WyVl8I39W/ZUTZLz4nqSP/oLYUrcnJrw462Ds8he4YKMov2efsTIw1BDGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmmirror.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmmirror.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stubborn-fs": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/stubborn-fs/-/stubborn-fs-1.2.5.tgz", + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/sucrase/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sucrase/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-baker": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/svg-baker/-/svg-baker-1.7.0.tgz", + "integrity": "sha512-nibslMbkXOIkqKVrfcncwha45f97fGuAOn1G99YwnwTj8kF9YiM6XexPcUso97NxOm6GsP0SIvYVIosBis1xLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.0", + "clone": "^2.1.1", + "he": "^1.1.1", + "image-size": "^0.5.1", + "loader-utils": "^1.1.0", + "merge-options": "1.0.1", + "micromatch": "3.1.0", + "postcss": "^5.2.17", + "postcss-prefix-selector": "^1.6.0", + "posthtml-rename-id": "^1.0", + "posthtml-svg-mode": "^1.0.3", + "query-string": "^4.3.2", + "traverse": "^0.6.6" + } + }, + "node_modules/svg-baker/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/braces": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/chalk": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/chalk/node_modules/supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svg-baker/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svg-baker/node_modules/fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/is-number/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/micromatch": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-3.1.0.tgz", + "integrity": "sha512-3StSelAE+hnRvMs8IdVW7Uhk8CVed5tp+kLLGlBP6WiRAXS21GPGu/Nat4WNPXj2Eoc24B02SaeoyozPMfj0/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.2.2", + "define-property": "^1.0.0", + "extend-shallow": "^2.0.1", + "extglob": "^2.0.2", + "fragment-cache": "^0.2.1", + "kind-of": "^5.0.2", + "nanomatch": "^1.2.1", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/postcss": { + "version": "5.2.18", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-5.2.18.tgz", + "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^1.1.3", + "js-base64": "^2.1.9", + "source-map": "^0.5.6", + "supports-color": "^3.2.3" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/svg-baker/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-baker/node_modules/supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^1.0.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/svg-baker/node_modules/to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svgo": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/svgo/-/svgo-2.8.0.tgz", + "integrity": "sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@trysound/sax": "0.2.0", + "commander": "^7.2.0", + "css-select": "^4.1.3", + "css-tree": "^1.1.3", + "csso": "^4.2.0", + "picocolors": "^1.0.0", + "stable": "^0.1.8" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/synckit": { + "version": "0.11.4", + "resolved": "https://registry.npmmirror.com/synckit/-/synckit-0.11.4.tgz", + "integrity": "sha512-Q/XQKRaJiLiFIBNN+mndW7S/RHxvwzuZS6ZwmRzUBqJBv/5QIKCEwkBC8GBf8EQJKYnaFs0wOZbKTXBPj8L9oQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "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/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "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/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmmirror.com/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-fs": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.2.tgz", + "integrity": "sha512-EsaAXwxmx8UB7FRKqeozqEPop69DXcmYwTQwXvyAPF352HJsPdkVhvTaDPYqfNgruveJIJy3TA2l+2zj8LJIJA==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/temp/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmmirror.com/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/temp/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/temp/node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/temp/node_modules/rimraf": { + "version": "2.6.3", + "resolved": "https://registry.npmmirror.com/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/text-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/text-hex/-/text-hex-1.0.0.tgz", + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/timespan": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/timespan/-/timespan-2.3.0.tgz", + "integrity": "sha512-0Jq9+58T2wbOyLth0EU+AUb6JMGCLaTWIykJFa7hyAybjVH9gpVMTfUAwo5fWAvtFt2Tjh/Elg8JtgNpnMnM8g==", + "engines": { + "node": ">= 0.2.0" + } + }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==", + "license": "MIT" + }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/tmp/-/tmp-0.2.3.tgz", + "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-object-path/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmmirror.com/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/to-regex/node_modules/define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-regex/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/traverse": { + "version": "0.6.11", + "resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.6.11.tgz", + "integrity": "sha512-vxXDZg8/+p3gblxB6BhhG5yWVn1kGRlaL8O78UDXc3wRnPizB5g83dcvWV1jpDMIPnjZjOFuxlMmE82XJ4407w==", + "dev": true, + "license": "MIT", + "dependencies": { + "gopd": "^1.2.0", + "typedarray.prototype.slice": "^1.0.5", + "which-typed-array": "^1.1.18" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "4.40.1", + "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.40.1.tgz", + "integrity": "sha512-9YvLNnORDpI+vghLU/Nf+zSv0kL47KbVJ1o3sKgoTefl6i+zebxbiDQWoe/oWWqPhIgQdRZRT1KA9sCPL810SA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray.prototype.slice": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/typedarray.prototype.slice/-/typedarray.prototype.slice-1.0.5.tgz", + "integrity": "sha512-q7QNVDGTdl702bVFiI5eY4l/HkgCM6at9KhcFbgUAzezHFbOVy4+0O/lCjsABEQwbZPravVfBIiBVGo89yzHFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "math-intrinsics": "^1.1.0", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-offset": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typeorm": { + "version": "0.3.22", + "resolved": "https://registry.npmmirror.com/typeorm/-/typeorm-0.3.22.tgz", + "integrity": "sha512-P/Tsz3UpJ9+K0oryC0twK5PO27zejLYYwMsE8SISfZc1lVHX+ajigiOyWsKbuXpEFMjD9z7UjLzY3+ElVOMMDA==", + "license": "MIT", + "dependencies": { + "@sqltools/formatter": "^1.2.5", + "ansis": "^3.17.0", + "app-root-path": "^3.1.0", + "buffer": "^6.0.3", + "dayjs": "^1.11.13", + "debug": "^4.4.0", + "dotenv": "^16.4.7", + "glob": "^10.4.5", + "sha.js": "^2.4.11", + "sql-highlight": "^6.0.0", + "tslib": "^2.8.1", + "uuid": "^11.1.0", + "yargs": "^17.7.2" + }, + "bin": { + "typeorm": "cli.js", + "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", + "typeorm-ts-node-esm": "cli-ts-node-esm.js" + }, + "engines": { + "node": ">=16.13.0" + }, + "funding": { + "url": "https://opencollective.com/typeorm" + }, + "peerDependencies": { + "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0", + "@sap/hana-client": "^2.12.25", + "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "hdb-pool": "^0.1.6", + "ioredis": "^5.0.4", + "mongodb": "^5.8.0 || ^6.0.0", + "mssql": "^9.1.1 || ^10.0.1 || ^11.0.1", + "mysql2": "^2.2.5 || ^3.0.1", + "oracledb": "^6.3.0", + "pg": "^8.5.1", + "pg-native": "^3.0.0", + "pg-query-stream": "^4.0.0", + "redis": "^3.1.1 || ^4.0.0", + "reflect-metadata": "^0.1.14 || ^0.2.0", + "sql.js": "^1.4.0", + "sqlite3": "^5.0.3", + "ts-node": "^10.7.0", + "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@google-cloud/spanner": { + "optional": true + }, + "@sap/hana-client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "hdb-pool": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mssql": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-native": { + "optional": true + }, + "pg-query-stream": { + "optional": true + }, + "redis": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "ts-node": { + "optional": true + }, + "typeorm-aurora-data-api-driver": { + "optional": true + } + } + }, + "node_modules/typeorm/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/typeorm/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmmirror.com/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typeorm/node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmmirror.com/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uglify-js": { + "version": "1.3.5", + "resolved": "https://registry.npmmirror.com/uglify-js/-/uglify-js-1.3.5.tgz", + "integrity": "sha512-YPX1DjKtom8l9XslmPFQnqWzTBkvI4N0pbkzLuPZZ4QTyig0uQqvZz9NgUdfEV+qccJzi7fVcGWdESvRIjWptQ==", + "bin": { + "uglifyjs": "bin/uglifyjs" + } + }, + "node_modules/uint8array-extras": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.4.0.tgz", + "integrity": "sha512-ZPtzy0hu4cZjv3z5NW9gfKnNLjoz4y6uv4HlelAjDK7sY/xOkKZv9xK/WQpcsBB3jEybChz9DPC2U/+cusjJVQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/uninstall": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/uninstall/-/uninstall-0.0.0.tgz", + "integrity": "sha512-pjP/0+A4gsbDVa8XH/S2GZdT9NPJW8NFMy3GI7HnsWG+NAmFSSj3QidNosXBI9cPtxxNExEDdhKFO6sli8K3mA==", + "license": "MIT" + }, + "node_modules/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha512-PcA2tsuGSF9cnySLHTLSh2qrQiJ70mn+r+Glzxv2TWZblxsxCC52BDlZoPCsz7STd9pN7EZetkWZBAvk4cgZdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-value/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha512-J8S0cEdWuQbqD9//tlZxiMuMNmxB8PlEwvYwuxsTmR1G5RXUePEX/SJn7aD0GMLieuZYSwNH0cQuJGwnYunXRQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/unset-value/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unset-value/node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/urix": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/urix/-/urix-0.1.0.tgz", + "integrity": "sha512-Am1ousAhSLBeB9cG/7k7r2R0zj50uDRlZHPGbazid5s9rlF1F/QKYObEKSIunSjIOkJZqwRRLpvewjEkM7pSqg==", + "deprecated": "Please see https://github.com/lydell/urix#deprecated", + "dev": true, + "license": "MIT" + }, + "node_modules/use": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util": { + "version": "0.10.4", + "resolved": "https://registry.npmmirror.com/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "license": "MIT", + "dependencies": { + "inherits": "2.0.3" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/util/node_modules/inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", + "license": "ISC" + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmmirror.com/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/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmmirror.com/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/vite": { + "version": "5.4.18", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.18.tgz", + "integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-electron": { + "version": "0.29.0", + "resolved": "https://registry.npmmirror.com/vite-plugin-electron/-/vite-plugin-electron-0.29.0.tgz", + "integrity": "sha512-HP0DI9Shg41hzt55IKYVnbrChWXHX95QtsEQfM+szQBpWjVhVGMlqRjVco6ebfQjWNr+Ga+PeoBjMIl8zMaufw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite-plugin-electron-renderer": "*" + }, + "peerDependenciesMeta": { + "vite-plugin-electron-renderer": { + "optional": true + } + } + }, + "node_modules/vite-plugin-electron-renderer": { + "version": "0.14.6", + "resolved": "https://registry.npmmirror.com/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", + "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite-plugin-svg-icons": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/vite-plugin-svg-icons/-/vite-plugin-svg-icons-2.0.1.tgz", + "integrity": "sha512-6ktD+DhV6Rz3VtedYvBKKVA2eXF+sAQVaKkKLDSqGUfnhqXl3bj5PPkVTl3VexfTuZy66PmINi8Q6eFnVfRUmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/svgo": "^2.6.1", + "cors": "^2.8.5", + "debug": "^4.3.3", + "etag": "^1.8.1", + "fs-extra": "^10.0.0", + "pathe": "^0.2.0", + "svg-baker": "1.7.0", + "svgo": "^2.8.0" + }, + "peerDependencies": { + "vite": ">=2.0.0" + } + }, + "node_modules/vite-plugin-svgr": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/vite-plugin-svgr/-/vite-plugin-svgr-4.3.0.tgz", + "integrity": "sha512-Jy9qLB2/PyWklpYy0xk0UU3TlU0t2UMpJXZvf+hWII1lAmRHrOUKi11Uw8N3rxoNk7atZNYO3pR3vI1f7oi+6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.3", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": ">=2.6.0" + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/when-exit": { + "version": "2.1.4", + "resolved": "https://registry.npmmirror.com/when-exit/-/when-exit-2.1.4.tgz", + "integrity": "sha512-4rnvd3A1t16PWzrBUcSDZqcAmsUIy4minDXT/CZ8F2mVDgd65i4Aalimgz1aQkRGU0iH5eT5+6Rx2TK8o443Pg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/winston": { + "version": "3.17.0", + "resolved": "https://registry.npmmirror.com/winston/-/winston-3.17.0.tgz", + "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "license": "MIT", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.7.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.9.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston-transport": { + "version": "4.9.0", + "resolved": "https://registry.npmmirror.com/winston-transport/-/winston-transport-4.9.0.tgz", + "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", + "license": "MIT", + "dependencies": { + "logform": "^2.7.0", + "readable-stream": "^3.6.2", + "triple-beam": "^1.3.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/winston/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmmirror.com/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wrench": { + "version": "1.3.9", + "resolved": "https://registry.npmmirror.com/wrench/-/wrench-1.3.9.tgz", + "integrity": "sha512-srTJQmLTP5YtW+F5zDuqjMEZqLLr/eJOZfDI5ibfPfRMeDh3oBUefAscuH0q5wBKE339ptH/S/0D18ZkfOfmKQ==", + "deprecated": "wrench.js is deprecated! You should check out fs-extra (https://github.com/jprichardson/node-fs-extra) for any operations you were using wrench for. Thanks for all the usage over the years.", + "engines": { + "node": ">=0.1.97" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/package.json b/project/aitoearn-wxplat/project/aitoearn-electron/package.json new file mode 100644 index 000000000..539c69931 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/package.json @@ -0,0 +1,131 @@ +{ + "name": "aiToEarn", + "version": "0.8.0", + "main": "dist-electron/main/index.js", + "description": "艺咖 win打包使用管理员的终端", + "author": "艺咖", + "license": "MIT", + "private": true, + "engines": { + "node": "20.x.x" + }, + "debug": { + "env": { + "VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/" + } + }, + "type": "module", + "postinstall": "electron-builder install-app-deps", + "scripts": { + "dev": "chcp 65001 && vite", + "dev:mac": "vite", + "build": "tsc && vite build && electron-builder", + "build:notsc": "vite build && electron-builder", + "preview": "vite preview", + "pretest": "vite build --mode=test", + "test": "vitest run", + "rebuild": "electron-rebuild -f -w better-sqlite3", + "lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:stylelint", + "lint:eslint": "eslint --cache --max-warnings 0 \"{src,mocks,electron}/**/*.{vue,ts,tsx}\" --fix", + "lint:prettier": "prettier --write \"{src,electron}/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint --cache --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/" + }, + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/icons": "^5.6.1", + "@electron-toolkit/preload": "^3.0.2", + "@electron-uikit/contextmenu": "^1.0.0", + "@electron-uikit/core": "^1.1.0", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@ffprobe-installer/ffprobe": "^2.1.2", + "@types/form-data": "^2.5.2", + "antd": "^5.23.1", + "antd-img-crop": "^4.24.0", + "axios": "^1.7.9", + "better-sqlite3": "^11.8.1", + "build": "^0.1.4", + "coordtransform": "^2.1.2", + "crc32": "^0.2.2", + "cropperjs": "^1.6.2", + "crypto": "^1.0.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.7", + "echarts": "^5.6.0", + "electron-log": "^5.3.0", + "electron-serve": "^2.1.1", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "events": "^3.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.2", + "fs": "^0.0.1-security", + "image-size": "^1.2.0", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "moment": "^2.30.1", + "node-cache": "^5.1.2", + "node-schedule": "^2.1.1", + "os": "^0.1.2", + "p-queue": "^8.1.0", + "path": "^0.12.7", + "qs": "^6.14.0", + "react-intersection-observer": "^9.16.0", + "react-masonry-css": "^1.0.16", + "react-router-dom": "6", + "react-sortablejs": "^6.1.4", + "reflect-metadata": "^0.2.2", + "sharp": "^0.33.5", + "sortablejs": "^1.15.6", + "typeorm": "^0.3.20", + "uninstall": "^0.0.0", + "uuid": "^11.0.5", + "vite": "^6.2.3", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "xml2js": "^0.6.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.1", + "@playwright/test": "^1.48.2", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/form-data": "^2.2.1", + "@types/lodash": "^4.17.15", + "@types/mime-types": "^2.1.4", + "@types/node-schedule": "^2.1.7", + "@types/qs": "^6.9.18", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/sortablejs": "^1.15.8", + "@types/xml2js": "^0.4.14", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "electron": "^33.2.0", + "electron-builder": "^26.0.12", + "electron-notarize": "^1.2.2", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-unused-imports": "^4.1.4", + "postcss": "^8.4.49", + "postcss-import": "^16.1.0", + "prettier": "^3.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sass": "^1.83.4", + "sass-loader": "^16.0.4", + "tailwindcss": "^3.4.15", + "typescript": "^5.4.2", + "vite": "^5.4.11", + "vite-plugin-electron": "^0.29.0", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^2.1.5" + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/package.sign.json.back b/project/aitoearn-wxplat/project/aitoearn-electron/package.sign.json.back new file mode 100644 index 000000000..f81686a0b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/package.sign.json.back @@ -0,0 +1,129 @@ +{ + "build": { + "afterSign": "scripts/notarize.cjs" + }, + "name": "aiToEarn", + "version": "0.6.0-alpha.4", + "main": "dist-electron/main/index.js", + "description": "艺咖 win打包使用管理员的终端", + "author": "艺咖", + "license": "MIT", + "private": true, + "engines": { + "node": "20.x.x" + }, + "debug": { + "env": { + "VITE_DEV_SERVER_URL": "http://127.0.0.1:7777/" + } + }, + "type": "module", + "scripts": { + "dev": "chcp 65001 && vite", + "dev:mac": "vite", + "build": "tsc && vite build && electron-builder", + "build:notsc": "vite build && electron-builder", + "preview": "vite preview", + "pretest": "vite build --mode=test", + "test": "vitest run", + "rebuild": "electron-rebuild -f -w better-sqlite3", + "lint": "npm run lint:eslint && npm run lint:prettier && npm run lint:stylelint", + "lint:eslint": "eslint --cache --max-warnings 0 \"{src,mocks,electron}/**/*.{vue,ts,tsx}\" --fix", + "lint:prettier": "prettier --write \"{src,electron}/**/*.{js,json,tsx,css,less,scss,vue,html,md}\"", + "lint:stylelint": "stylelint --cache --fix \"**/*.{vue,less,postcss,css,scss}\" --cache --cache-location node_modules/.cache/stylelint/" + }, + "dependencies": { + "@ant-design/colors": "^7.2.0", + "@ant-design/icons": "^5.6.1", + "@ffmpeg-installer/ffmpeg": "^1.1.0", + "@ffprobe-installer/ffprobe": "^2.1.2", + "@types/form-data": "^2.5.2", + "antd": "^5.23.1", + "antd-img-crop": "^4.24.0", + "axios": "^1.7.9", + "better-sqlite3": "^11.8.1", + "coordtransform": "^2.1.2", + "crc32": "^0.2.2", + "cropperjs": "^1.6.2", + "crypto": "^1.0.1", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.7", + "echarts": "^5.6.0", + "electron-log": "^5.3.0", + "electron-serve": "^2.1.1", + "electron-store": "^10.0.0", + "electron-updater": "^6.3.9", + "events": "^3.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "form-data": "^4.0.2", + "fs": "^0.0.1-security", + "image-size": "^1.2.0", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "moment": "^2.30.1", + "node-cache": "^5.1.2", + "node-schedule": "^2.1.1", + "os": "^0.1.2", + "p-queue": "^8.1.0", + "path": "^0.12.7", + "qs": "^6.14.0", + "react-intersection-observer": "^9.16.0", + "react-masonry-css": "^1.0.16", + "react-router-dom": "6", + "react-sortablejs": "^6.1.4", + "reflect-metadata": "^0.2.2", + "sharp": "^0.33.5", + "sortablejs": "^1.15.6", + "typeorm": "^0.3.20", + "uninstall": "^0.0.0", + "uuid": "^11.0.5", + "vite": "^6.2.3", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "xml2js": "^0.6.2", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.1", + "@playwright/test": "^1.48.2", + "@types/fluent-ffmpeg": "^2.1.27", + "@types/form-data": "^2.2.1", + "@types/lodash": "^4.17.15", + "@types/mime-types": "^2.1.4", + "@types/node-schedule": "^2.1.7", + "@types/qs": "^6.9.18", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/sortablejs": "^1.15.8", + "@types/xml2js": "^0.4.14", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "electron": "^33.2.0", + "electron-builder": "^26.0.12", + "electron-notarize": "^1.2.2", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.37.4", + "eslint-plugin-unused-imports": "^4.1.4", + "postcss": "^8.4.49", + "postcss-import": "^16.1.0", + "prettier": "^3.0.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "sass": "^1.83.4", + "sass-loader": "^16.0.4", + "tailwindcss": "^3.4.15", + "typescript": "^5.4.2", + "vite": "^5.4.11", + "vite-plugin-electron": "^0.29.0", + "vite-plugin-electron-renderer": "^0.14.6", + "vite-plugin-svg-icons": "^2.0.1", + "vite-plugin-svgr": "^4.3.0", + "vitest": "^2.1.5" + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/postcss.config.cjs b/project/aitoearn-wxplat/project/aitoearn-electron/postcss.config.cjs new file mode 100644 index 000000000..825cb1862 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/postcss.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + plugins: { + 'postcss-import': {}, + 'tailwindcss/nesting': {}, + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/public/.project b/project/aitoearn-wxplat/project/aitoearn-electron/public/.project new file mode 100644 index 000000000..1e4aa8f1a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/public/.project @@ -0,0 +1,28 @@ + + + public + + + + + + com.aptana.ide.core.unifiedBuilder + + + + + + com.aptana.projects.webnature + + + + 1740588515250 + + 26 + + org.eclipse.ui.ide.multiFilter + 1.0-name-matches-false-false-node_modules + + + + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.icns b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.icns new file mode 100644 index 000000000..9796a8ae7 Binary files /dev/null and b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.icns differ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.ico b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.ico new file mode 100644 index 000000000..06118e7dd Binary files /dev/null and b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/favicon.ico differ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/splash.png b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/splash.png new file mode 100644 index 000000000..c1af481a7 Binary files /dev/null and b/project/aitoearn-wxplat/project/aitoearn-electron/public/assets/splash.png differ diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/public/splash.html b/project/aitoearn-wxplat/project/aitoearn-electron/public/splash.html new file mode 100644 index 000000000..cfd185c10 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/public/splash.html @@ -0,0 +1,79 @@ + + + + + + +
+ 启动图 +
哎哟赚
+
+ + + \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/scripts/entitlements.mac.plist b/project/aitoearn-wxplat/project/aitoearn-electron/scripts/entitlements.mac.plist new file mode 100644 index 000000000..9f7cf5148 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/scripts/entitlements.mac.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/scripts/notarize.cjs b/project/aitoearn-wxplat/project/aitoearn-electron/scripts/notarize.cjs new file mode 100644 index 000000000..e7c602543 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/scripts/notarize.cjs @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2025-04-17 19:22:11 + * @LastEditTime: 2025-04-29 16:02:02 + * @LastEditors: nevin + * @Description: + */ +// 修改后(CommonJS) +const { notarize } = require('@electron/notarize'); + +exports.default = async function notarizing(context) { + // console.log('--2222---', context); + + const { productFilename, version } = context.packager.appInfo; + const { electronPlatformName, outDir } = context; + if (electronPlatformName !== 'darwin') return; + + let appPath = `${outDir}/${productFilename}-${version}-arm64.dmg`; + console.log('notarizing-------', appPath); + + // return await notarize({ + + // }); +}; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.editorconfig b/project/aitoearn-wxplat/project/aitoearn-electron/server/.editorconfig new file mode 100644 index 000000000..5b8220e37 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.editorconfig @@ -0,0 +1,14 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.example b/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.example new file mode 100644 index 000000000..0b73157e8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.example @@ -0,0 +1,40 @@ +NODE_ENV=production +SERVER_PORT=3000 +MONGO_URI=mongodb://root:YOUR_MONGO_PASSWORD@your_mongo_host:port/?directConnection=true +MONGO_DB=att +REDIS_HOST=your_redis_host +REDIS_PORT=your_redis_port +REDIS_PASSWORD=your_redis_password +REDIS_DB=0 +WX_MCH_ID=your_wx_mch_id +WX_KEY=your_wx_key +WX_NOTIFY_URL=https://your_domain/api/pay/back/wx +WX_APP_ID=your_wx_app_id +WX_APP_SECRET=your_wx_app_secret +WX_GZH_ID=your_wx_gzh_id +WX_GZH_SECRET=your_wx_gzh_secret +WX_GZH_TOKEN=your_wx_gzh_token +WX_GZH_AES_KEY=your_wx_gzh_aes_key +OSS_KEY_ID=your_oss_key_id +OSS_KEY_SECRET=your_oss_key_secret +OSS_REGION=your_oss_region +OSS_BUCKET=your_oss_bucket +OSS_URL=https://your_oss_bucket.oss-region.aliyuncs.com +OSS_SECRET=true +SMS_ACCESS_KEY_ID=your_sms_access_key_id +SMS_ACCESS_KEY_SECRET=your_sms_access_key_secret +SMS_ENDPOINT=dysmsapi.aliyuncs.com +SMS_REGION_ID= +SMS_SIGN_NAME=your_sms_sign_name +SMS_TEMPLATE_CODE=your_sms_template_code +QWEN_KEY=your_qwen_key +REALNAME_ACCESS_KEY_ID=your_realname_access_key_id +REALNAME_ACCESS_KEY_SECRET=your_realname_access_key_secret +TENCENT_TMS_SECRET_ID=your_tencent_tms_secret_id +TENCENT_TMS_SECRET_KEY=your_tencent_tms_secret_key +GOOGLE_WEB_CLIENT_ID=your_google_web_client_id +GOOGLE_WEB_CLIENT_SECRET=your_google_web_client_secret +GOOGLE_RENDER_URL=https://your_render_url +TWITTER_WEB_CLIENT_ID=your_twitter_web_client_id +TWITTER_WEB_CLIENT_SECRET=your_twitter_web_client_secret +TWITTER_WEB_RENDER_URL=https://your_render_url \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.pro b/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.pro new file mode 100644 index 000000000..0b73157e8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.env.pro @@ -0,0 +1,40 @@ +NODE_ENV=production +SERVER_PORT=3000 +MONGO_URI=mongodb://root:YOUR_MONGO_PASSWORD@your_mongo_host:port/?directConnection=true +MONGO_DB=att +REDIS_HOST=your_redis_host +REDIS_PORT=your_redis_port +REDIS_PASSWORD=your_redis_password +REDIS_DB=0 +WX_MCH_ID=your_wx_mch_id +WX_KEY=your_wx_key +WX_NOTIFY_URL=https://your_domain/api/pay/back/wx +WX_APP_ID=your_wx_app_id +WX_APP_SECRET=your_wx_app_secret +WX_GZH_ID=your_wx_gzh_id +WX_GZH_SECRET=your_wx_gzh_secret +WX_GZH_TOKEN=your_wx_gzh_token +WX_GZH_AES_KEY=your_wx_gzh_aes_key +OSS_KEY_ID=your_oss_key_id +OSS_KEY_SECRET=your_oss_key_secret +OSS_REGION=your_oss_region +OSS_BUCKET=your_oss_bucket +OSS_URL=https://your_oss_bucket.oss-region.aliyuncs.com +OSS_SECRET=true +SMS_ACCESS_KEY_ID=your_sms_access_key_id +SMS_ACCESS_KEY_SECRET=your_sms_access_key_secret +SMS_ENDPOINT=dysmsapi.aliyuncs.com +SMS_REGION_ID= +SMS_SIGN_NAME=your_sms_sign_name +SMS_TEMPLATE_CODE=your_sms_template_code +QWEN_KEY=your_qwen_key +REALNAME_ACCESS_KEY_ID=your_realname_access_key_id +REALNAME_ACCESS_KEY_SECRET=your_realname_access_key_secret +TENCENT_TMS_SECRET_ID=your_tencent_tms_secret_id +TENCENT_TMS_SECRET_KEY=your_tencent_tms_secret_key +GOOGLE_WEB_CLIENT_ID=your_google_web_client_id +GOOGLE_WEB_CLIENT_SECRET=your_google_web_client_secret +GOOGLE_RENDER_URL=https://your_render_url +TWITTER_WEB_CLIENT_ID=your_twitter_web_client_id +TWITTER_WEB_CLIENT_SECRET=your_twitter_web_client_secret +TWITTER_WEB_RENDER_URL=https://your_render_url \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.eslintrc.js b/project/aitoearn-wxplat/project/aitoearn-electron/server/.eslintrc.js new file mode 100644 index 000000000..def3009af --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.eslintrc.js @@ -0,0 +1,27 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + tsconfigRootDir: __dirname, + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + "prettier/prettier": ["error", { "endOfLine": "auto" }] + }, +}; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.gitignore b/project/aitoearn-wxplat/project/aitoearn-electron/server/.gitignore new file mode 100644 index 000000000..7d3e66aa3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.gitignore @@ -0,0 +1,55 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development +.env.test +.env.production + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/.prettierrc b/project/aitoearn-wxplat/project/aitoearn-electron/server/.prettierrc new file mode 100644 index 000000000..3cd0ff0dd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/Dockerfile b/project/aitoearn-wxplat/project/aitoearn-electron/server/Dockerfile new file mode 100644 index 000000000..824118b8d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/Dockerfile @@ -0,0 +1,24 @@ +# 第一阶段:构建 +FROM nestjs:v10 AS builder + +WORKDIR /app +COPY package*.json ./ +RUN yarn install +COPY . . +RUN yarn run build + +# 第二阶段:创建最终运行镜像 +FROM nestjs:v10 + +# 构建参数 +ENV WORK_HOME /home/server +COPY --from=builder /app/dist /home/server/dist +COPY --from=builder /app/node_modules /home/server/node_modules + +WORKDIR $WORK_HOME + +# 声明对外暴露的端口 +EXPOSE 3000 +# 配置容器启动时运行的命令 +ENTRYPOINT ["node","/home/server/dist/src/main.js"] + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/README.md b/project/aitoearn-wxplat/project/aitoearn-electron/server/README.md new file mode 100644 index 000000000..17cb5444d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/README.md @@ -0,0 +1,19 @@ +# 凯团团服务端 + +## 简介 +凯团团服务端是基于 NestJS 构建的后端项目,用于支持聚合类业务平台。 + +## 环境配置 +项目使用环境变量进行配置管理。请根据实际需要配置环境变量: + +1. 复制示例配置文件: + ```bash + cp .env.example .env + ``` + +2. 修改 `.env` 文件中的配置项,替换为您实际的配置值。 + +## 部署 +```bash +pm2 start pm2.json +``` \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bilibili.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bilibili.config.ts new file mode 100644 index 000000000..5e7c94ea2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bilibili.config.ts @@ -0,0 +1,15 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2025-02-25 15:03:36 + * @Description: B站配置 + */ +export default () => ({ + BILIBILI_CONFIG: { + CLIENT_NAME: process.env.BILIBILI_CLIENT_NAME || '', + CLIENT_ID: process.env.BILIBILI_CLIENT_ID || '', + CLIENT_SECRET: process.env.BILIBILI_CLIENT_SECRET || '', + AUTH_BACK_URL: process.env.BILIBILI_AUTH_BACK_URL || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bullMq.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bullMq.config.ts new file mode 100644 index 000000000..eba98c190 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/bullMq.config.ts @@ -0,0 +1,15 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2025-01-13 17:26:58 + * @Description: 队列配置 + */ +export default () => ({ + BULLMQ_REDIS_CONFIG: { + HOST: process.env.REDIS_HOST || '127.0.0.1', + PORT: Number(process.env.REDIS_PORT) || 6379, + PASSWORD: process.env.REDIS_PASSWORD || '', + DB: process.env.REDIS_DB, + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/google.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/google.config.ts new file mode 100644 index 000000000..03d6b8cda --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/google.config.ts @@ -0,0 +1,12 @@ +export default () => ({ + GOOGLE_CONFIG: { + WEB_CLIENT_SECRET: process.env.GOOGLE_WEB_CLIENT_SECRET || '', + WEB_CLIENT_ID: process.env.GOOGLE_WEB_CLIENT_ID || '', + WEB_RENDER_URL: process.env.GOOGLE_RENDER_URL || '', + }, + TWITTER_CONFIG: { + WEB_CLIENT_SECRET: process.env.TWITTER_WEB_CLIENT_SECRET || '', + WEB_CLIENT_ID: process.env.TWITTER_WEB_CLIENT_ID || '', + WEB_RENDER_URL: process.env.TWITTER_WEB_RENDER_URL || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mail.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mail.config.ts new file mode 100644 index 000000000..9e1ace35f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mail.config.ts @@ -0,0 +1,28 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2024-07-05 15:52:13 + * @Description: 邮件服务配置文件 + */ +import { MailerOptions } from '@nestjs-modules/mailer'; + +export const mailConfig: MailerOptions = { + transport: { + host: process.env.MAIL_HOST || 'smtp.feishu.cn', + port: 465, + secure: true, + auth: { + user: process.env.MAIL_AUTH_USER || 'hello@aiearn.ai', + pass: process.env.MAIL_AUTH_PASS || 'xxxx', + }, + debug: true, + }, + defaults: { + from: `aitoearn `, + }, +}; + +export default () => ({ + MAIL_CONFIG: mailConfig, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mongo.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mongo.config.ts new file mode 100644 index 000000000..5a7f14edb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/mongo.config.ts @@ -0,0 +1,15 @@ +/* + * @Author: nevin + * @Date: 2024-08-30 14:58:16 + * @LastEditTime: 2025-02-25 16:52:34 + * @LastEditors: nevin + * @Description: mongo数据库配置 + */ +import { MongooseModuleFactoryOptions } from '@nestjs/mongoose'; + +export default (): { MONGO_CONFIG: MongooseModuleFactoryOptions } => ({ + MONGO_CONFIG: { + uri: process.env.MONGO_URI || 'wwww', + dbName: process.env.MONGO_DB || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/oss.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/oss.config.ts new file mode 100644 index 000000000..b99097500 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/oss.config.ts @@ -0,0 +1,19 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2024-06-17 14:24:27 + * @Description: OSS模块配置文件 + */ +export default () => ({ + OSS_CONFIG: { + INIT_OPTION: { + region: 'oss-cn-beijing', + accessKeyId: process.env.OSS_KEY_ID || 'xxxx', + accessKeySecret: process.env.OSS_KEY_SECRET || 'xxxx', + bucket: process.env.OSS_BUCKET || '', + secret: process.env.OSS_SECRET === 'true', + }, + HOST_URL: process.env.OSS_URL || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/realAuth.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/realAuth.config.ts new file mode 100644 index 000000000..95544d2bf --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/realAuth.config.ts @@ -0,0 +1,6 @@ +export default () => ({ + REAL_NAME_CONFIG: { + accessKeyId: process.env.REALNAME_ACCESS_KEY_ID || 'xxxx', + accessKeySecret: process.env.REALNAME_ACCESS_KEY_SECRET || 'xxxx', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/redis.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/redis.config.ts new file mode 100644 index 000000000..533a21b6a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/redis.config.ts @@ -0,0 +1,18 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2024-08-31 19:27:09 + * @Description: redis缓存配置文件 + */ +import { RedisOptions } from 'ioredis'; +export default (): { REDIS_CONFIG: RedisOptions } => ({ + REDIS_CONFIG: { + name: 'default', + host: process.env.REDIS_HOST || '127.0.0.1', + port: Number(process.env.REDIS_PORT) || 6379, + password: process.env.REDIS_PASSWORD || '', + db: Number.parseInt(process.env.REDIS_DB) || 0, + connectTimeout: 10000, + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/server.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/server.config.ts new file mode 100644 index 000000000..4b7d295f3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/server.config.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2024-06-17 14:24:40 + * @Description: 服务配置文件 + */ +export default () => ({ + SERVER_CONFIG: { + NODE_ENV: process.env.NODE_ENV, + PORT: process.env.SERVER_PORT + ? Number.parseInt(process.env.SERVER_PORT) + : 7000, + ENABLE_SWAGGER: process.env.NODE_ENV !== 'production' ? true : false, + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/sms.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/sms.config.ts new file mode 100644 index 000000000..1be972e2e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/sms.config.ts @@ -0,0 +1,14 @@ +export default () => ({ + SMS_CONFIG: { + config: { + accessKeyId: process.env.SMS_ACCESS_KEY_ID || 'xxxx', + accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET || 'xxxx', + endpoint: process.env.SMS_ENDPOINT || 'dysmsapi.aliyuncs.com', + }, + defaults: { + regionId: process.env.SMS_REGION_ID || undefined, + signName: process.env.SMS_SIGN_NAME || '', + templateCode: process.env.SMS_TEMPLATE_CODE || '', + }, + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tiktok.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tiktok.config.ts new file mode 100644 index 000000000..7338ebcc9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tiktok.config.ts @@ -0,0 +1,36 @@ +import { registerAs } from '@nestjs/config'; + +export default registerAs('tiktok', () => ({ + // TikTok开发者平台的客户端密钥 + clientId: process.env.TIKTOK_CLIENT_ID || '', + clientSecret: process.env.TIKTOK_CLIENT_SECRET || '', + redirectUri: process.env.TIKTOK_REDIRECT_URI || 'https://platapi.aitoearn.cn', + + // TikTok API基础URL + apiBaseUrl: 'https://open.tiktokapis.com', + + // TikTok OAuth 2.0终端点 + authUrl: 'https://www.tiktok.com/v2/auth/authorize', + tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', + revokeUrl: 'https://open.tiktokapis.com/v2/oauth/revoke/', + refreshTokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/', + + // TikTok API终端点 + // videosListUrl: 'https://open.tiktokapis.com/v2/video/list/', + videoUploadUrl: 'https://open-upload.tiktokapis.com/v2', + // videoPublishUrl: 'https://open.tiktokapis.com/v2/video/publish/', + + // API版本 + apiVersion: 'v2', + + // 需要的权限范围 + scopes: [ + 'user.info.basic', + 'user.info.open_id', + 'user.info.profile', + 'user.info.stats', + 'video.list', + 'video.upload', + 'video.publish', + ].join(','), +})); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tms.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tms.config.ts new file mode 100644 index 000000000..8f1f348d0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/tms.config.ts @@ -0,0 +1,6 @@ +export default () => ({ + TMS_CONFIG: { + secretId: process.env.TENCENT_TMS_SECRET_ID || '', + secretKey: process.env.TENCENT_TMS_SECRET_KEY || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/config/wx.config.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/wx.config.ts new file mode 100644 index 000000000..f48918351 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/config/wx.config.ts @@ -0,0 +1,31 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 11:05:02 + * @LastEditors: nevin + * @LastEditTime: 2025-02-25 15:03:36 + * @Description: 微信配置 + */ +import * as fs from 'fs'; +import { join } from 'path'; + +export default () => ({ + WX_CONFIG: { + APP_ID: process.env.WX_APP_ID || '', + APP_SECRET: process.env.WX_APP_SECRET || '', + MCH_ID: process.env.WX_MCH_ID || '', + PUBLIC_KEY: fs.readFileSync( + join(__dirname, '..', 'src/files/wxcert/apiclient_cert.pem'), + ), + PRIVATE_KEY: fs.readFileSync( + join(__dirname, '..', 'src/files/wxcert/apiclient_key.pem'), + ), + KEY: process.env.WX_KEY || '', + NOTIFY_URL: process.env.WX_NOTIFY_URL || '', + }, + WX_GZH: { + WX_GZH_ID: process.env.WX_GZH_ID || '', + WX_GZH_SECRET: process.env.WX_GZH_SECRET || '', + WX_GZH_TOKEN: process.env.WX_GZH_TOKEN || '', + WX_GZH_AES_KEY: process.env.WX_GZH_AES_KEY || '', + }, +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/nest-cli.json b/project/aitoearn-wxplat/project/aitoearn-electron/server/nest-cli.json new file mode 100644 index 000000000..a4af1b123 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/nest-cli.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "assets": [ + { + "include": "files/**", + "outDir": "dist/src", + "watchAssets": true + }, + { + "include": "modules/tools/kwaiSign/kuaiShoSignCore.js", + "outDir": "dist/src", + "watchAssets": true + }, + "views/", + "public/" + ], + "watchAssets": true + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/package.json b/project/aitoearn-wxplat/project/aitoearn-electron/server/package.json new file mode 100644 index 000000000..70f26a668 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/package.json @@ -0,0 +1,127 @@ +{ + "name": "aituantuan-server", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "debug": "nest start --debug --watch", + "prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json" + }, + "dependencies": { + "@alicloud/cloudauth20190307": "3.5.0", + "@alicloud/credentials": "^2.4.3", + "@alicloud/dypnsapi20170525": "1.2.3", + "@alicloud/dysmsapi20170525": "^3.1.1", + "@alicloud/openapi-client": "^0.4.12", + "@alicloud/tea-util": "^1.4.10", + "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/axios": "^3.1.3", + "@nestjs/bullmq": "^11.0.2", + "@nestjs/common": "^10.0.0", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.0.0", + "@nestjs/event-emitter": "^2.1.1", + "@nestjs/jwt": "^10.2.0", + "@nestjs/mongoose": "^10.1.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.1.2", + "@nestjs/swagger": "^8.1.1", + "ali-oss": "^6.22.0", + "axios": "^1.7.9", + "body-parser": "^2.2.0", + "bullmq": "^5.52.2", + "cheerio": "^1.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "express": "^5.1.0", + "express-session": "^1.18.1", + "fast-xml-parser": "^5.0.7", + "form-data": "^4.0.3", + "fs": "0.0.1-security", + "google-auth-library": "^9.15.1", + "googleapis": "^148.0.0", + "hbs": "^4.2.0", + "he": "^1.2.0", + "ioredis": "^5.4.2", + "lodash": "^4.17.21", + "moment": "^2.30.1", + "mongodb": "^6.12.0", + "mongoose": "^8.9.4", + "multer": "^2.0.0", + "natural": "^8.0.1", + "nest-wechatpay-node-v3": "^1.0.2", + "nodemailer": "^7.0.3", + "openai": "^4.86.2", + "path": "^0.12.7", + "qs": "^6.14.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.1", + "swagger-ui-express": "^5.0.1", + "tencentcloud-sdk-nodejs-tms": "^4.0.1052", + "uuid": "^11.0.5", + "wechatpay-node-v3": "^2.2.1", + "xml-js": "^1.6.11", + "xml2js": "^0.6.2", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@hapi/joi": "^17.1.1", + "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", + "@nestjs/testing": "^10.0.0", + "@testcontainers/mongodb": "^10.16.0", + "@testcontainers/redis": "^10.16.0", + "@types/express": "^4.17.17", + "@types/jest": "^29.5.2", + "@types/multer": "^1.4.12", + "@types/node": "^20.3.1", + "@types/nodemailer": "^6.4.17", + "@types/qs": "^6.14.0", + "@types/supertest": "^6.0.0", + "@types/xml-js": "^1.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.42.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.5.0", + "prettier": "^3.0.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "testcontainers": "^10.16.0", + "ts-jest": "^29.1.0", + "ts-loader": "^9.4.3", + "ts-node": "^10.9.1", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.1.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/pm2.json b/project/aitoearn-wxplat/project/aitoearn-electron/server/pm2.json new file mode 100644 index 000000000..33cdca54e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/pm2.json @@ -0,0 +1,117 @@ +{ + "apps": [ + { + "name": "aitoearn-server", + "cwd": "dist", + "script": "src/main.js", + "instances": "2", + "exec_mode": "cluster", + "env": { + "NODE_ENV": "development", + "SERVER_PORT": "3000", + "MONGO_URI": "mongodb://root:REPLACE_WITH_RANDOM_STRING@dbconn.sealosbja.site:31051/katuantuan?authSource=admin&directConnection=true", + "MONGO_DB": "katuantuan", + "REDIS_HOST": "101.200.211.50", + "REDIS_PORT": "6379", + "REDIS_PASSWORD": "REPLACE_WITH_RANDOM_STRING", + "REDIS_DB": "1", + "WX_MCH_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_KEY": "REPLACE_WITH_RANDOM_STRING", + "WX_NOTIFY_URL": "https://qqdsdabnbtqd.sealosbja.site/api/pay/back/wx", + "WX_APP_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_APP_SECRET": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_SECRET": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_TOKEN": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_AES_KEY": "REPLACE_WITH_RANDOM_STRING", + "OSS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "OSS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "OSS_REGION": "oss-cn-beijing", + "OSS_BUCKET": "ai-to-earn", + "OSS_URL": "https://ai-to-earn.oss-cn-beijing.aliyuncs.com", + "OSS_SECRET": "true", + "SMS_ACCESS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "SMS_ACCESS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "SMS_ENDPOINT": "dysmsapi.aliyuncs.com", + "SMS_REGION_ID": "", + "SMS_SIGN_NAME": "艺咖", + "SMS_TEMPLATE_CODE": "REPLACE_WITH_RANDOM_STRING", + "REALNAME_ACCESS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "REALNAME_ACCESS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "QWEN_KEY": "REPLACE_WITH_RANDOM_STRING", + "TENCENT_TMS_SECRET_ID": "REPLACE_WITH_RANDOM_STRING", + "TENCENT_TMS_SECRET_KEY": "REPLACE_WITH_RANDOM_STRING", + "MAIL_HOST": "smtp.feishu.cn", + "MAIL_AUTH_USER": "REPLACE_WITH_RANDOM_STRING", + "MAIL_AUTH_PASS": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_CLIENT_NAME": "aitoearn", + "BILIBILI_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_AUTH_BACK_URL": "https://apitest.aitoearn.cn", + "GOOGLE_WEB_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "GOOGLE_WEB_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "GOOGLE_RENDER_URL": "https://platapi.aitoearn.cn", + "TWITTER_WEB_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "TWITTER_WEB_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "TWITTER_WEB_RENDER_URL": "https://platapi.aitoearn.cn", + "TIKTOK_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "TIKTOK_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "TIKTOK_REDIRECT_URI": "https://platapi.aitoearn.cn" + }, + "env_production": { + "NODE_ENV": "production", + "SERVER_PORT": "3000", + "MONGO_URI": "mongodb://root:REPLACE_WITH_RANDOM_STRING@dbconn.sealosbja.site:33948/?directConnection=true", + "MONGO_DB": "att", + "REDIS_HOST": "dbconn.sealosbja.site", + "REDIS_PORT": "46963", + "REDIS_PASSWORD": "REPLACE_WITH_RANDOM_STRING", + "REDIS_DB": "0", + "WX_MCH_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_KEY": "REPLACE_WITH_RANDOM_STRING", + "WX_NOTIFY_URL": "https://qqdsdabnbtqd.sealosbja.site/api/pay/back/wx", + "WX_APP_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_APP_SECRET": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_ID": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_SECRET": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_TOKEN": "REPLACE_WITH_RANDOM_STRING", + "WX_GZH_AES_KEY": "REPLACE_WITH_RANDOM_STRING", + "OSS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "OSS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "OSS_REGION": "oss-cn-beijing", + "OSS_BUCKET": "ai-to-earn", + "OSS_URL": "https://ai-to-earn.oss-cn-beijing.aliyuncs.com", + "OSS_SECRET": "true", + "SMS_ACCESS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "SMS_ACCESS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "SMS_ENDPOINT": "dysmsapi.aliyuncs.com", + "SMS_REGION_ID": "", + "SMS_SIGN_NAME": "艺咖", + "SMS_TEMPLATE_CODE": "REPLACE_WITH_RANDOM_STRING", + "REALNAME_ACCESS_KEY_ID": "REPLACE_WITH_RANDOM_STRING", + "REALNAME_ACCESS_KEY_SECRET": "REPLACE_WITH_RANDOM_STRING", + "QWEN_KEY": "REPLACE_WITH_RANDOM_STRING", + "TENCENT_TMS_SECRET_ID": "REPLACE_WITH_RANDOM_STRING", + "TENCENT_TMS_SECRET_KEY": "REPLACE_WITH_RANDOM_STRING", + "MAIL_HOST": "smtp.feishu.cn", + "MAIL_AUTH_USER": "REPLACE_WITH_RANDOM_STRING", + "MAIL_AUTH_PASS": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_CLIENT_NAME": "aitoearn", + "BILIBILI_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "BILIBILI_AUTH_BACK_URL": "https://api.aitoearn.cn", + "GOOGLE_WEB_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "GOOGLE_WEB_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "GOOGLE_RENDER_URL": "https://platapi.aitoearn.cn", + "TWITTER_WEB_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "TWITTER_WEB_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "TWITTER_WEB_RENDER_URL": "https://platapi.aitoearn.cn", + "TIKTOK_CLIENT_ID": "REPLACE_WITH_RANDOM_STRING", + "TIKTOK_CLIENT_SECRET": "REPLACE_WITH_RANDOM_STRING", + "TIKTOK_REDIRECT_URI": "https://platapi.aitoearn.cn" + }, + "log_date_format": "YYYY-MM-DD_HH:mm Z", + "merge_logs": true + } + ] +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/pnpm-lock.yaml b/project/aitoearn-wxplat/project/aitoearn-electron/server/pnpm-lock.yaml new file mode 100644 index 000000000..c579c255c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/pnpm-lock.yaml @@ -0,0 +1,11139 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@alicloud/cloudauth20190307': + specifier: 3.5.0 + version: 3.5.0 + '@alicloud/credentials': + specifier: ^2.4.3 + version: 2.4.3 + '@alicloud/dypnsapi20170525': + specifier: 1.2.3 + version: 1.2.3 + '@alicloud/dysmsapi20170525': + specifier: ^3.1.1 + version: 3.1.1 + '@alicloud/openapi-client': + specifier: ^0.4.12 + version: 0.4.12 + '@alicloud/tea-util': + specifier: ^1.4.10 + version: 1.4.10 + '@nestjs-modules/mailer': + specifier: ^2.0.2 + version: 2.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(nodemailer@7.0.3) + '@nestjs/axios': + specifier: ^3.1.3 + version: 3.1.3(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.7.9)(rxjs@7.8.1) + '@nestjs/bullmq': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.52.2) + '@nestjs/common': + specifier: ^10.0.0 + version: 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/config': + specifier: ^3.3.0 + version: 3.3.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) + '@nestjs/core': + specifier: ^10.0.0 + version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/event-emitter': + specifier: ^2.1.1 + version: 2.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + '@nestjs/jwt': + specifier: ^10.2.0 + version: 10.2.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)) + '@nestjs/mongoose': + specifier: ^10.1.0 + version: 10.1.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(mongoose@8.9.4)(rxjs@7.8.1) + '@nestjs/platform-express': + specifier: ^10.0.0 + version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + '@nestjs/schedule': + specifier: ^4.1.2 + version: 4.1.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + '@nestjs/swagger': + specifier: ^8.1.1 + version: 8.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + ali-oss: + specifier: ^6.22.0 + version: 6.22.0 + axios: + specifier: ^1.7.9 + version: 1.7.9 + body-parser: + specifier: ^2.2.0 + version: 2.2.0 + bullmq: + specifier: ^5.52.2 + version: 5.52.2 + cheerio: + specifier: ^1.0.0 + version: 1.0.0 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.1 + express: + specifier: ^5.1.0 + version: 5.1.0 + express-session: + specifier: ^1.18.1 + version: 1.18.1 + fast-xml-parser: + specifier: ^5.0.7 + version: 5.2.1 + form-data: + specifier: ^4.0.3 + version: 4.0.3 + fs: + specifier: 0.0.1-security + version: 0.0.1-security + google-auth-library: + specifier: ^9.15.1 + version: 9.15.1 + googleapis: + specifier: ^148.0.0 + version: 148.0.0 + hbs: + specifier: ^4.2.0 + version: 4.2.0 + he: + specifier: ^1.2.0 + version: 1.2.0 + ioredis: + specifier: ^5.4.2 + version: 5.4.2 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + moment: + specifier: ^2.30.1 + version: 2.30.1 + mongodb: + specifier: ^6.12.0 + version: 6.12.0 + mongoose: + specifier: ^8.9.4 + version: 8.9.4 + multer: + specifier: ^2.0.0 + version: 2.0.0 + natural: + specifier: ^8.0.1 + version: 8.0.1 + nest-wechatpay-node-v3: + specifier: ^1.0.2 + version: 1.0.2 + nodemailer: + specifier: ^7.0.3 + version: 7.0.3 + openai: + specifier: ^4.86.2 + version: 4.96.0 + path: + specifier: ^0.12.7 + version: 0.12.7 + qs: + specifier: ^6.14.0 + version: 6.14.0 + reflect-metadata: + specifier: ^0.2.0 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.1 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.1.0) + tencentcloud-sdk-nodejs-tms: + specifier: ^4.0.1052 + version: 4.0.1052 + uuid: + specifier: ^11.0.5 + version: 11.0.5 + wechatpay-node-v3: + specifier: ^2.2.1 + version: 2.2.1 + xml-js: + specifier: ^1.6.11 + version: 1.6.11 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + yaml: + specifier: ^2.7.0 + version: 2.7.0 + devDependencies: + '@hapi/joi': + specifier: ^17.1.1 + version: 17.1.1 + '@nestjs/cli': + specifier: ^10.0.0 + version: 10.4.9 + '@nestjs/schematics': + specifier: ^10.0.0 + version: 10.2.3(chokidar@3.6.0)(typescript@5.7.3) + '@nestjs/testing': + specifier: ^10.0.0 + version: 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15) + '@testcontainers/mongodb': + specifier: ^10.16.0 + version: 10.16.0 + '@testcontainers/redis': + specifier: ^10.16.0 + version: 10.16.0 + '@types/express': + specifier: ^4.17.17 + version: 4.17.21 + '@types/jest': + specifier: ^29.5.2 + version: 29.5.14 + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 + '@types/node': + specifier: ^20.3.1 + version: 20.17.12 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 + '@types/qs': + specifier: ^6.14.0 + version: 6.14.0 + '@types/supertest': + specifier: ^6.0.0 + version: 6.0.2 + '@types/xml-js': + specifier: ^1.0.0 + version: 1.0.0 + '@typescript-eslint/eslint-plugin': + specifier: ^8.0.0 + version: 8.19.1(@typescript-eslint/parser@8.19.1(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^8.0.0 + version: 8.19.1(eslint@8.57.1)(typescript@5.7.3) + eslint: + specifier: ^8.42.0 + version: 8.57.1 + eslint-config-prettier: + specifier: ^9.1.0 + version: 9.1.0(eslint@8.57.1) + eslint-plugin-prettier: + specifier: ^5.2.1 + version: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.4.2) + jest: + specifier: ^29.5.0 + version: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + prettier: + specifier: ^3.0.0 + version: 3.4.2 + source-map-support: + specifier: ^0.5.21 + version: 0.5.21 + supertest: + specifier: ^7.0.0 + version: 7.0.0 + testcontainers: + specifier: ^10.16.0 + version: 10.16.0 + ts-jest: + specifier: ^29.1.0 + version: 29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)))(typescript@5.7.3) + ts-loader: + specifier: ^9.4.3 + version: 9.5.2(typescript@5.7.3)(webpack@5.97.1) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@types/node@20.17.12)(typescript@5.7.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.1.3 + version: 5.7.3 + +packages: + + '@alicloud/cloudauth20190307@3.5.0': + resolution: {integrity: sha512-cUeNWfinrx7rvjGCoSurBtSlsnRbC45wi+5p3DyC+uf0JvA8dB7LbhwYXOt+bk1LIzG3OzL+zx9dNtO1wwGyyA==} + + '@alicloud/credentials@1.1.0': + resolution: {integrity: sha512-sCZjWWvOCJW/jkBBas6PJsofF0m3xxU0Yhq45rxs18IiHUsHqGeo1DqG4zkHToAuE689hA/GjFivtO6NOmJWHw==} + + '@alicloud/credentials@2.4.3': + resolution: {integrity: sha512-r2thNtthchTz/c8/HryGSey1vY0UZx2FkAvb+vd+j7xhD/v/KUwnp8RJNQKNG3E4kfs4wSx2bgDSkcPAiXHQLQ==} + + '@alicloud/darabonba-array@0.1.0': + resolution: {integrity: sha512-y4oM4O2uXiroUjfWBLEXRHMm1279rWpkWWNalF7DFQyO5awJ/e0d631prU4i10ytKzo8XJd12eCHmm3IOW85+g==} + + '@alicloud/darabonba-encode-util@0.0.1': + resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==} + + '@alicloud/darabonba-encode-util@0.0.2': + resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==} + + '@alicloud/darabonba-map@0.0.1': + resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==} + + '@alicloud/darabonba-signature-util@0.0.4': + resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==} + + '@alicloud/darabonba-string@1.0.3': + resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==} + + '@alicloud/dypnsapi20170525@1.2.3': + resolution: {integrity: sha512-24UQXNlfNyiFG2KBjXvxPixU8pDIbaQznztiqb7e/UO3v8ghiy8WjDVY29JH8T//E/KS4BKzPdHRHATm6sDX+A==} + + '@alicloud/dysmsapi20170525@3.1.1': + resolution: {integrity: sha512-UvrQo9p1b7A/JH209jPFLdtuYGywMrn4vWl48LwGxgZOH21i/LQXJKGhIUkeN9/CbdWsW709lkJ9kWvzmQZ5gQ==} + + '@alicloud/endpoint-util@0.0.1': + resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==} + + '@alicloud/gateway-pop@0.0.6': + resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==} + + '@alicloud/gateway-spi@0.0.8': + resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==} + + '@alicloud/http-core-sdk@1.0.0': + resolution: {integrity: sha512-ZODX85jwCf63Fmzj+pYZq85z8+SZzNg/FL+oW1/L/sRM8oj70+1+pdG0RHynAxBXkjNYt4eLIPrBvIEeVfx+LQ==} + + '@alicloud/openapi-client@0.4.12': + resolution: {integrity: sha512-WuKfFqwY3/+wuNawzfJAirNA00XDI7fm9fUhWK7siGZEh0R2XJR0Y54MLW7WWItX06fAghUvnDhKWZH3AgN+yg==} + + '@alicloud/openapi-core@1.0.2': + resolution: {integrity: sha512-7ALrOsj8MUUZkrPJNvWMARh0XykxaBsHEHthmlen3rk7EoTVbljyjdKn0VqFKe4AsqBl4F5I9wda2mxQxWAEHg==} + + '@alicloud/openapi-util@0.2.9': + resolution: {integrity: sha512-GUEYtX3lDv+WaZoDFCb0h9aZ8+IlajnSAxSHjiITbNtjCpZbA/vfd7Z/ST9YaPoT34nGqDNKiQTjqpLhaKtYBw==} + + '@alicloud/openapi-util@0.3.2': + resolution: {integrity: sha512-EC2JvxdcOgMlBAEG0+joOh2IB1um8CPz9EdYuRfTfd1uP8Yc9D8QRUWVGjP6scnj6fWSOaHFlit9H6PrJSyFow==} + + '@alicloud/openplatform20191219@2.0.0': + resolution: {integrity: sha512-x2o725mfNTML1rCoabrQ/9QIL7lnNGf1QsLV/7AH7IWp3tGLKKOQc7RyXIuU3E699lvF8dNsK3fZL6knjaKenA==} + + '@alicloud/oss-baseclient@1.2.0': + resolution: {integrity: sha512-4K5sQTd7rCLfk+DbwIKEbISWKKGlso9bBz1qzZkDBG6OGvLjwh36/2BK2O9omvXMUVzpj7jrJSkFU/oWZY4zEQ==} + + '@alicloud/oss-client@1.1.4': + resolution: {integrity: sha512-xbFcZtcCJhR7UAdWnvccd9VJe4HD+Q+ovpdgbAzqHzWppjhiIKEXs9GEWS3kqclyoNzpz7lRG67GJA2KDzRtjA==} + + '@alicloud/oss-util@0.0.1': + resolution: {integrity: sha512-5cfcNVhN7YCvwaI1iAheppMFhIVbeFuJZMgS65mxDA0F9ud6kMCkxiYnlYPvms/lvVEuVkEHbR5939WH8ysz0w==} + + '@alicloud/oss-util@0.0.3': + resolution: {integrity: sha512-7gyxvZMDA/DVPcsr61VgCGAVNpsqiSKUE2R+0oONqOZFEP2bqkCbift14qOY+SL9C5+dvmLIXQluCtL00KVSLw==} + + '@alicloud/rpc-util@0.0.1': + resolution: {integrity: sha512-YUG6cPs9zq4WQVar0PX+s3ZnrmWf5nm62CzlBX4mlkkaCRyCf/VOGuYIPf+j1w8FvPYNv0zudeDU9tyZ20xWwA==} + + '@alicloud/sts-sdk@1.0.2': + resolution: {integrity: sha512-WOv1qkNW7r2S6I0f2Qz8+7D5uU7bgE5vHxDWExcXmomMgr2i8JM6GhJeFPNVOzSWPgQi6Ujc+EATZ6ies9+UMA==} + + '@alicloud/tea-fileform@1.2.0': + resolution: {integrity: sha512-+uKR4BsJssR254Isaqc/6Dc2iDC6AylBtrkf05yxqBd95cP1OfvzzRgdJ9MfipLWJkUtzVaVdnnbZwzGuLJVxQ==} + + '@alicloud/tea-typescript@1.8.0': + resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==} + + '@alicloud/tea-util@1.4.10': + resolution: {integrity: sha512-VEsXWP2dlJLvsY2THj+sH++zwxQRz3Y5BQ8EkfnFems36RkngQKYOLsoto5nR6ej1Gf6I+0IOgBXrkRdpNCQ1g==} + + '@alicloud/tea-util@1.4.9': + resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==} + + '@alicloud/tea-xml@0.0.3': + resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@angular-devkit/core@17.3.11': + resolution: {integrity: sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + peerDependencies: + chokidar: ^3.5.2 + peerDependenciesMeta: + chokidar: + optional: true + + '@angular-devkit/schematics-cli@17.3.11': + resolution: {integrity: sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + hasBin: true + + '@angular-devkit/schematics@17.3.11': + resolution: {integrity: sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==} + engines: {node: ^18.13.0 || >=20.9.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} + + '@babel/code-frame@7.26.2': + resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.26.5': + resolution: {integrity: sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.26.0': + resolution: {integrity: sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.26.5': + resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.26.5': + resolution: {integrity: sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.25.9': + resolution: {integrity: sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.26.0': + resolution: {integrity: sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.26.5': + resolution: {integrity: sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.25.9': + resolution: {integrity: sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.26.0': + resolution: {integrity: sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.26.5': + resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.26.0': + resolution: {integrity: sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.25.9': + resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.25.9': + resolution: {integrity: sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.1': + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.25.9': + resolution: {integrity: sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.26.5': + resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.26.5': + resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + engines: {node: '>=6.9.0'} + + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@css-inline/css-inline-android-arm-eabi@0.14.1': + resolution: {integrity: sha512-LNUR8TY4ldfYi0mi/d4UNuHJ+3o8yLQH9r2Nt6i4qeg1i7xswfL3n/LDLRXvGjBYqeEYNlhlBQzbPwMX1qrU6A==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@css-inline/css-inline-android-arm64@0.14.1': + resolution: {integrity: sha512-tH5us0NYGoTNBHOUHVV7j9KfJ4DtFOeTLA3cM0XNoMtArNu2pmaaBMFJPqECzavfXkLc7x5Z22UPZYjoyHfvCA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@css-inline/css-inline-darwin-arm64@0.14.1': + resolution: {integrity: sha512-QE5W1YRIfRayFrtrcK/wqEaxNaqLULPI0gZB4ArbFRd3d56IycvgBasDTHPre5qL2cXCO3VyPx+80XyHOaVkag==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@css-inline/css-inline-darwin-x64@0.14.1': + resolution: {integrity: sha512-mAvv2sN8awNFsbvBzlFkZPbCNZ6GCWY5/YcIz7V5dPYw+bHHRbjnlkNTEZq5BsDxErVrMIGvz05PGgzuNvZvdQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@css-inline/css-inline-linux-arm-gnueabihf@0.14.1': + resolution: {integrity: sha512-AWC44xL0X7BgKvrWEqfSqkT2tJA5kwSGrAGT+m0gt11wnTYySvQ6YpX0fTY9i3ppYGu4bEdXFjyK2uY1DTQMHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@css-inline/css-inline-linux-arm64-gnu@0.14.1': + resolution: {integrity: sha512-drj0ciiJgdP3xKXvNAt4W+FH4KKMs8vB5iKLJ3HcH07sNZj58Sx++2GxFRS1el3p+GFp9OoYA6dgouJsGEqt0Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@css-inline/css-inline-linux-arm64-musl@0.14.1': + resolution: {integrity: sha512-FzknI+st8eA8YQSdEJU9ykcM0LZjjigBuynVF5/p7hiMm9OMP8aNhWbhZ8LKJpKbZrQsxSGS4g9Vnr6n6FiSdQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@css-inline/css-inline-linux-x64-gnu@0.14.1': + resolution: {integrity: sha512-yubbEye+daDY/4vXnyASAxH88s256pPati1DfVoZpU1V0+KP0BZ1dByZOU1ktExurbPH3gZOWisAnBE9xon0Uw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@css-inline/css-inline-linux-x64-musl@0.14.1': + resolution: {integrity: sha512-6CRAZzoy1dMLPC/tns2rTt1ZwPo0nL/jYBEIAsYTCWhfAnNnpoLKVh5Nm+fSU3OOwTTqU87UkGrFJhObD/wobQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@css-inline/css-inline-win32-x64-msvc@0.14.1': + resolution: {integrity: sha512-nzotGiaiuiQW78EzsiwsHZXbxEt6DiMUFcDJ6dhiliomXxnlaPyBfZb6/FMBgRJOf6sknDt/5695OttNmbMYzg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@css-inline/css-inline@0.14.1': + resolution: {integrity: sha512-u4eku+hnPqqHIGq/ZUQcaP0TrCbYeLIYBaK7qClNRGZbnh8RC4gVxLEIo8Pceo1nOK9E5G4Lxzlw5KnXcvflfA==} + engines: {node: '>= 10'} + + '@darabonba/typescript@1.0.2': + resolution: {integrity: sha512-sTMBt+nC4aUSDfHy+Hl/nWbrHXuNMsgr8qN5xb0/QrvBR2U6ckko/In1N88Pj8bqbsrV1TtpKRGZDHDhZ3UVpA==} + + '@eslint-community/eslint-utils@4.4.1': + resolution: {integrity: sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + + '@fidm/asn1@1.0.4': + resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==} + engines: {node: '>= 8'} + + '@fidm/x509@1.2.1': + resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==} + engines: {node: '>= 8'} + + '@hapi/address@4.1.0': + resolution: {integrity: sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==} + deprecated: Moved to 'npm install @sideway/address' + + '@hapi/formula@2.0.0': + resolution: {integrity: sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A==} + deprecated: Moved to 'npm install @sideway/formula' + + '@hapi/hoek@9.3.0': + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + + '@hapi/joi@17.1.1': + resolution: {integrity: sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==} + deprecated: Switch to 'npm install joi' + + '@hapi/pinpoint@2.0.1': + resolution: {integrity: sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==} + + '@hapi/topo@5.1.0': + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@ioredis/commands@1.2.0': + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@29.7.0': + resolution: {integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/core@29.7.0': + resolution: {integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/environment@29.7.0': + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect-utils@29.7.0': + resolution: {integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/expect@29.7.0': + resolution: {integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/fake-timers@29.7.0': + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/globals@29.7.0': + resolution: {integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/reporters@29.7.0': + resolution: {integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/source-map@29.6.3': + resolution: {integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-result@29.7.0': + resolution: {integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/test-sequencer@29.7.0': + resolution: {integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/transform@29.7.0': + resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.6': + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@ljharb/through@2.3.13': + resolution: {integrity: sha512-/gKJun8NNiWGZJkGzI/Ragc53cOdcLNdzjLaIa+GEjguQs0ulsurx8WN0jijdK9yPqDvziX995sMRLyLt1uZMQ==} + engines: {node: '>= 0.4'} + + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + + '@mongodb-js/saslprep@1.1.9': + resolution: {integrity: sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==} + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + + '@nestjs-modules/mailer@2.0.2': + resolution: {integrity: sha512-+z4mADQasg0H1ZaGu4zZTuKv2pu+XdErqx99PLFPzCDNTN/q9U59WPgkxVaHnsvKHNopLj5Xap7G4ZpptduoYw==} + peerDependencies: + '@nestjs/common': '>=7.0.9' + '@nestjs/core': '>=7.0.9' + nodemailer: '>=6.4.6' + + '@nestjs/axios@3.1.3': + resolution: {integrity: sha512-RZ/63c1tMxGLqyG3iOCVt7A72oy4x1eM6QEhd4KzCYpaVWW0igq0WSREeRoEZhIxRcZfDfIIkvsOMiM7yfVGZQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + axios: ^1.3.1 + rxjs: ^6.0.0 || ^7.0.0 + + '@nestjs/bull-shared@11.0.2': + resolution: {integrity: sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + + '@nestjs/bullmq@11.0.2': + resolution: {integrity: sha512-Lq6lGpKkETsm0RDcUktlzsthFoE3A5QTMp2FwPi1eztKqKD6/90KS1TcnC9CJFzjpUaYnQzIMrlNs55e+/wsHA==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + bullmq: ^3.0.0 || ^4.0.0 || ^5.0.0 + + '@nestjs/cli@10.4.9': + resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} + engines: {node: '>= 16.14'} + hasBin: true + peerDependencies: + '@swc/cli': ^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 + '@swc/core': ^1.3.62 + peerDependenciesMeta: + '@swc/cli': + optional: true + '@swc/core': + optional: true + + '@nestjs/common@10.4.15': + resolution: {integrity: sha512-vaLg1ZgwhG29BuLDxPA9OAcIlgqzp9/N8iG0wGapyUNTf4IY4O6zAHgN6QalwLhFxq7nOI021vdRojR1oF3bqg==} + peerDependencies: + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/config@3.3.0': + resolution: {integrity: sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + rxjs: ^7.1.0 + + '@nestjs/core@10.4.15': + resolution: {integrity: sha512-UBejmdiYwaH6fTsz2QFBlC1cJHM+3UDeLZN+CiP9I1fRv2KlBZsmozGLbV5eS1JAVWJB4T5N5yQ0gjN8ZvcS2w==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + '@nestjs/websockets': ^10.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + '@nestjs/websockets': + optional: true + + '@nestjs/event-emitter@2.1.1': + resolution: {integrity: sha512-6L6fBOZTyfFlL7Ih/JDdqlCzZeCW0RjCX28wnzGyg/ncv5F/EOeT1dfopQr1loBRQ3LTgu8OWM7n4zLN4xigsg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/jwt@10.2.0': + resolution: {integrity: sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/mapped-types@2.0.6': + resolution: {integrity: sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/mongoose@10.1.0': + resolution: {integrity: sha512-1ExAnZUfh2QffEaGjqYGgVPy/sYBQCVLCLqVgkcClKx/BCd0QNgND8MB70lwyobp3nm/+nbGQqBpu9F3/hgOCw==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + mongoose: ^6.0.2 || ^7.0.0 || ^8.0.0 + rxjs: ^7.0.0 + + '@nestjs/platform-express@10.4.15': + resolution: {integrity: sha512-63ZZPkXHjoDyO7ahGOVcybZCRa7/Scp6mObQKjcX/fTEq1YJeU75ELvMsuQgc8U2opMGOBD7GVuc4DV0oeDHoA==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + + '@nestjs/schedule@4.1.2': + resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + + '@nestjs/schematics@10.2.3': + resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} + peerDependencies: + typescript: '>=4.8.2' + + '@nestjs/swagger@8.1.1': + resolution: {integrity: sha512-5Mda7H1DKnhKtlsb0C7PYshcvILv8UFyUotHzxmWh0G65Z21R3LZH/J8wmpnlzL4bmXIfr42YwbEwRxgzpJ5sQ==} + peerDependencies: + '@fastify/static': ^6.0.0 || ^7.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + + '@nestjs/testing@10.4.15': + resolution: {integrity: sha512-eGlWESkACMKti+iZk1hs6FUY/UqObmMaa8HAN9JLnaYkoLf1Jeh+EuHlGnfqo/Rq77oznNLIyaA3PFjrFDlNUg==} + peerDependencies: + '@nestjs/common': ^10.0.0 + '@nestjs/core': ^10.0.0 + '@nestjs/microservices': ^10.0.0 + '@nestjs/platform-express': ^10.0.0 + peerDependenciesMeta: + '@nestjs/microservices': + optional: true + '@nestjs/platform-express': + optional: true + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nuxtjs/opencollective@0.3.2': + resolution: {integrity: sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.1.1': + resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + + '@sinclair/typebox@0.27.8': + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@10.3.0': + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + + '@testcontainers/mongodb@10.16.0': + resolution: {integrity: sha512-ZYD2uv/iRrBD4mFxiLjtaRJ0d2AmpTMv6p94WoVkTsXZ4mX7Q81SPdS58NDh8ucgshVc1adJ9kEsh7tgDbIGrQ==} + + '@testcontainers/redis@10.16.0': + resolution: {integrity: sha512-+NJO1tfMXvUQiMfa9Y8qqwaFP6yV0BBg2cNA+iNz+Zt6kzTyxMapBiKCL8pWsCqWolz2mqZwuDtfcHBxdoCzAw==} + + '@tsconfig/node10@1.0.11': + resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@3.3.34': + resolution: {integrity: sha512-mH9SuIb8NuTDsMus5epcbTzSbEo52fKLBMo0zapzYIAIyfDqoIFn7L3trekHLKC8qmxGV++pPUP4YqQ9n5v2Zg==} + + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + + '@types/eslint-scope@3.7.7': + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + + '@types/eslint@9.6.1': + resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.21': + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + + '@types/graceful-fs@4.1.9': + resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@29.5.14': + resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/jsonwebtoken@9.0.5': + resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==} + + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/mime@2.0.3': + resolution: {integrity: sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==} + + '@types/mjml-core@4.15.1': + resolution: {integrity: sha512-qu8dUksU8yXX18qMTFINkM4uoz7WQYC5F14lcWeSNmWbulaGG0KG19yeZwpx75b9RJXr8WI/FRHH0LyQTU9JbA==} + + '@types/mjml@4.7.4': + resolution: {integrity: sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==} + + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} + + '@types/node-fetch@2.6.12': + resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@18.19.71': + resolution: {integrity: sha512-evXpcgtZm8FY4jqBSN8+DmOTcVkkvTmAayeo4Wf3m1xAruyVGzGuDh/Fb/WWX2yLItUiho42ozyJjB0dw//Tkw==} + + '@types/node@20.17.12': + resolution: {integrity: sha512-vo/wmBgMIiEA23A/knMfn/cf37VnuF52nZh5ZoW0GWt4e4sxNquibrMRJ7UQsA06+MBx9r/H1jsI9grYjQCQlw==} + + '@types/node@22.10.7': + resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} + + '@types/nodemailer@6.4.17': + resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + + '@types/ssh2-streams@0.1.12': + resolution: {integrity: sha512-Sy8tpEmCce4Tq0oSOYdfqaBpA3hDM8SoxoFh5vzFsu2oL+znzGz8oVWW7xb4K920yYMUY+PIG31qZnFMfPWNCg==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.4': + resolution: {integrity: sha512-9JTQgVBWSgq6mAen6PVnrAmty1lqgCMvpfN+1Ck5WRUsyMYPa6qd50/vMJ0y1zkGpOEgLzm8m8Dx/Y5vRouLaA==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.2': + resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + + '@types/validator@13.12.2': + resolution: {integrity: sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==} + + '@types/webidl-conversions@7.0.3': + resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + + '@types/whatwg-url@11.0.5': + resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} + + '@types/xml-js@1.0.0': + resolution: {integrity: sha512-tRJYQN/uAD8Br9K+pqqzJNd/htIxQaFy6ppfNEWbwsAoWRK3oAxzROCGA39GHT+E3BHLyuBnSB7XKsnJ0s4w2g==} + deprecated: This is a stub types definition for xml-js (https://github.com/nashwaan/xml-js). xml-js provides its own type definitions, so you don't need @types/xml-js installed! + + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.33': + resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==} + + '@typescript-eslint/eslint-plugin@8.19.1': + resolution: {integrity: sha512-tJzcVyvvb9h/PB96g30MpxACd9IrunT7GF9wfA9/0TJ1LxGOJx1TdPzSbBBnNED7K9Ka8ybJsnEpiXPktolTLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/parser@8.19.1': + resolution: {integrity: sha512-67gbfv8rAwawjYx3fYArwldTQKoYfezNUT4D5ioWetr/xCrxXxvleo3uuiFuKfejipvq+og7mjz3b0G2bVyUCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/scope-manager@8.19.1': + resolution: {integrity: sha512-60L9KIuN/xgmsINzonOcMDSB8p82h95hoBfSBtXuO4jlR1R9L1xSkmVZKgCPVfavDlXihh4ARNjXhh1gGnLC7Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.19.1': + resolution: {integrity: sha512-Rp7k9lhDKBMRJB/nM9Ksp1zs4796wVNyihG9/TU9R6KCJDNkQbc2EOKjrBtLYh3396ZdpXLtr/MkaSEmNMtykw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/types@8.19.1': + resolution: {integrity: sha512-JBVHMLj7B1K1v1051ZaMMgLW4Q/jre5qGK0Ew6UgXz1Rqh+/xPzV1aW581OM00X6iOfyr1be+QyW8LOUf19BbA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.19.1': + resolution: {integrity: sha512-jk/TZwSMJlxlNnqhy0Eod1PNEvCkpY6MXOXE/WLlblZ6ibb32i2We4uByoKPv1d0OD2xebDv4hbs3fm11SMw8Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/utils@8.19.1': + resolution: {integrity: sha512-IxG5gLO0Ne+KaUc8iW1A+XuKLd63o4wlbI1Zp692n1xojCl/THvgIKXJXBZixTh5dd5+yTJ/VXH7GJaaw21qXA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + + '@typescript-eslint/visitor-keys@8.19.1': + resolution: {integrity: sha512-fzmjU8CHK853V/avYZAvuVut3ZTfwN5YtMaoi+X9Y9MA9keaWNHC3zEQ9zvyX/7Hj+5JkNyK1l7TOR2hevHB6Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.2.1': + resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + + address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + + afinn-165-financialmarketnews@3.0.0: + resolution: {integrity: sha512-0g9A1S3ZomFIGDTzZ0t6xmv4AuokBvBmpes8htiyHpH7N4xDmvSQL6UxL/Zcs2ypRb3VwgCscaD8Q3zEawKYhw==} + + afinn-165@1.0.4: + resolution: {integrity: sha512-7+Wlx3BImrK0HiG6y3lU4xX7SpBPSSu8T9iguPMlaueRFxjbYwAQrp9lqZUuFikqKbd/en8lVREILvP2J80uJA==} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + + agentkeepalive@3.5.3: + resolution: {integrity: sha512-yqXL+k5rr8+ZRpOAntkaaRgWgE5o8ESAj5DyRmVTCSoZxXmqemb9Dd7T4i5UzwuERdLAJUy6XzR9zFVuf0kzkw==} + engines: {node: '>= 4.0.0'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + alce@1.2.0: + resolution: {integrity: sha512-XppPf2S42nO2WhvKzlwzlfcApcXHzjlod30pKmcWjRgLOtqoe5DMuqdiYoM6AgyXksc6A6pV4v1L/WW217e57w==} + engines: {node: '>=0.8.0'} + + ali-oss@6.22.0: + resolution: {integrity: sha512-X8CHo+wsjCBvDaEvuibFOi3SZxiCBZSRUURrXH0upoVwu3SuW3e+PTVK7xw+uN6EyTcAESqrngrQimhp8iBzsQ==} + engines: {node: '>=8'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + apparatus@0.0.10: + resolution: {integrity: sha512-KLy/ugo33KZA7nugtQ7O0E1c8kQ52N3IvD/XgIh4w/Nr28ypfkwDfA67F1ev4N1m5D+BOk1+b2dEJDfpj/VvZg==} + engines: {node: '>=0.2.6'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-timsort@1.0.3: + resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + + b4a@1.6.7: + resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} + + babel-jest@29.7.0: + resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.8.0 + + babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + + babel-plugin-jest-hoist@29.6.3: + resolution: {integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + babel-preset-current-node-syntax@1.1.0: + resolution: {integrity: sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-preset-jest@29.6.3: + resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@babel/core': ^7.0.0 + + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.5.4: + resolution: {integrity: sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==} + + bare-fs@4.0.1: + resolution: {integrity: sha512-ilQs4fm/l9eMfWY2dY0WCIUplSUp7U0CT1vrqMg1MUdeZl4fypu5UP0XcDBK5WBQPJAKP1b7XEodISmekH/CEg==} + engines: {bare: '>=1.7.0'} + + bare-os@3.4.0: + resolution: {integrity: sha512-9Ous7UlnKbe3fMi7Y+qh0DwAup6A1JkYgPnjvMDNOlmnxNRQvQ/7Nst+OnUQKzk0iAT0m9BisbDVp9gCv8+ETA==} + engines: {bare: '>=1.6.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.6.4: + resolution: {integrity: sha512-G6i3A74FjNq4nVrrSTUz5h3vgXzBJnjmWAVlBWaZETkgu+LgKd7AiyOml3EDJY1AHlIbBHKDXE+TUT53Ff8OaA==} + peerDependencies: + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + bare-events: + optional: true + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bignumber.js@4.1.0: + resolution: {integrity: sha512-eJzYkFYy9L4JzXsbymsFn3p54D+llV27oTQ+ziJG7WFRheJcNZilgVXMG0LoZtlQSKBsJdWtLFqOD0u+U0jZKA==} + + bignumber.js@9.3.0: + resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + bowser@1.9.4: + resolution: {integrity: sha512-9IdMmj2KjigRq6oWhmwv1W36pDuA4STQZ8q6YO9um+x07xgYNCD3Oou+WP/3L1HNz7iqythGet3/p4wvc8AAwQ==} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.4: + resolution: {integrity: sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + bson@6.10.1: + resolution: {integrity: sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==} + engines: {node: '>=16.20.1'} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.6: + resolution: {integrity: sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==} + engines: {node: '>=10.0.0'} + + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + + bullmq@5.52.2: + resolution: {integrity: sha512-fK/dKIv8ymyys4K+zeNEPA+yuYWzRPmBWUmwIMz8DvYekadl8VG19yUx94Na0n0cLAi+spdn3a/+ufkYK7CBUg==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.1: + resolution: {integrity: sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.3: + resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camel-case@3.0.0: + resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001692: + resolution: {integrity: sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==} + + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + + chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + + cheerio@1.0.0-rc.12: + resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==} + engines: {node: '>= 6'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.1: + resolution: {integrity: sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==} + + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.1: + resolution: {integrity: sha512-2VEG9JICxIqTpoK1eMzZqaV+u/EiwEJkMGzTrZf6sU/fwsnOITVgYJ8yojSy6CaXtO9V0Cc6ZQZ8h8m4UBuLwQ==} + + clean-css@4.2.4: + resolution: {integrity: sha512-EJUDT7nDVFDvaQgAo2G/PJvxmp1o/c6iXLbswsBbUFXi1Nr+AjA2cKmfbKDMjMvzEe75g3P6JkaDDAKk96A85A==} + engines: {node: '>= 4.0'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.2: + resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + + comment-json@4.2.5: + resolution: {integrity: sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==} + engines: {node: '>= 6'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + consola@2.15.3: + resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} + + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + copy-to@2.0.1: + resolution: {integrity: sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + corser@2.0.1: + resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} + engines: {node: '>= 0.4.0'} + + cosmiconfig@8.3.6: + resolution: {integrity: sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + create-jest@29.7.0: + resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + + cron@3.2.1: + resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-select@5.1.0: + resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + + css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + + dateformat@2.2.0: + resolution: {integrity: sha512-GODcnWq3YGoTnygPfi02ygEiRxqUxpJwuRHjdhJYuxpcZmDq4rjBiXYmbCCzStxo176ixfLT6i4NPwQooRySnw==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-user-agent@1.0.0: + resolution: {integrity: sha512-bDF7bg6OSNcSwFWPu4zYKpVkJZQYVrAANMYB8bc9Szem1D0yKdm4sa/rOCs2aC9+2GMqQ7KnwtZRvDhmLF0dXw==} + engines: {node: '>= 0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + digest-header@1.1.0: + resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==} + engines: {node: '>= 8.0.0'} + + display-notification@2.0.0: + resolution: {integrity: sha512-TdmtlAcdqy1NU+j7zlkDdMnCL878zriLaBmoD9quOoq1ySSSGv03l0hXK5CvIFZlIfFI/hizqdQuW+Num7xuhw==} + engines: {node: '>=4'} + + docker-compose@0.24.8: + resolution: {integrity: sha512-plizRs/Vf15H+GCVxq2EUvyPK7ei9b/cVesHvjnX4xaXjM9spHe2Ytq0BitndFgvTJ3E3NljPNUEl7BAN43iZw==} + engines: {node: '>= 6.0.0'} + + docker-modem@3.0.8: + resolution: {integrity: sha512-f0ReSURdM3pcKPNS30mxOHSbaFLcknGmQjwSfmbcdOw1XWKXVhukM3NJHhr7NpY9BIyyWQb0EBo3KQvvuU5egQ==} + engines: {node: '>= 8.0'} + + dockerode@3.3.5: + resolution: {integrity: sha512-/0YNa3ZDNeLr/tSckmD69+Gq+qVNhvKfAHNeZJBnp7EOP6RGKV8ORrJHkUn20So5wU+xxT7+1n5u8PjHbfjbSA==} + engines: {node: '>= 8.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@3.3.0: + resolution: {integrity: sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA==} + engines: {node: '>= 4'} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + dotenv@16.4.5: + resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + editorconfig@1.0.4: + resolution: {integrity: sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==} + engines: {node: '>=14'} + hasBin: true + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-to-chromium@1.5.80: + resolution: {integrity: sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + encoding-sniffer@0.2.0: + resolution: {integrity: sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==} + + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + + end-or-error@1.0.1: + resolution: {integrity: sha512-OclLMSug+k2A0JKuf494im25ANRBVW8qsjmwbgX7lQ8P82H21PQ1PWkoYwb9y5yMBS69BPlwtzdIFClo3+7kOQ==} + engines: {node: '>= 0.11.14'} + + enhanced-resolve@5.18.0: + resolution: {integrity: sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.0: + resolution: {integrity: sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==} + engines: {node: '>=0.12'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.6.0: + resolution: {integrity: sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==} + + es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-goat@3.0.0: + resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==} + engines: {node: '>=10'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-applescript@1.0.0: + resolution: {integrity: sha512-4/hFwoYaC6TkpDn9A3pTC52zQPArFeXuIfhUtCGYdauTzXVP9H3BDr3oO/QzQehMpLDC7srvYgfwvImPFGfvBA==} + engines: {node: '>=0.10.0'} + + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@9.1.0: + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.2.1: + resolution: {integrity: sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '*' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esprima@1.2.5: + resolution: {integrity: sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@1.9.3: + resolution: {integrity: sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==} + engines: {node: '>=0.10.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + execa@0.10.0: + resolution: {integrity: sha512-7XOMnz8Ynx1gGo/3hyV9loYNPWM94jG3+3T3Y8tsfSstFmETmENCMU/A/zj8Lyaj1lkgEepKepvd6240tBRvlw==} + engines: {node: '>=4'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit@0.1.2: + resolution: {integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==} + engines: {node: '>= 0.8.0'} + + expect@29.7.0: + resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + express-session@1.18.1: + resolution: {integrity: sha512-a5mtTqEaZvBCL9A9aqkrtfz+3SMDhOVUnjafjo+s7A9Txkq+SVX2DLvSp1Zrv4uCXa3lMSK3viWnh9Gg07PBUA==} + engines: {node: '>= 0.8.0'} + + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + extend-object@1.0.0: + resolution: {integrity: sha512-0dHDIXC7y7LDmCh/lp1oYkmv73K25AMugQI07r8eFopkW6f7Ufn1q+ETMsJjnV9Am14SlElkqy3O92r6xEaxPw==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fast-uri@3.0.5: + resolution: {integrity: sha512-5JnBCWpFlMo0a3ciDy/JckMzzv1U9coZrIhedq+HXxxUfDTAiS0LA8OKVao4G9BxmCVck/jtA5r3KAtRWEyD8Q==} + + fast-xml-parser@5.2.1: + resolution: {integrity: sha512-Kqq/ewnRACQ20e0BlQ5KqHRYWRBp7yv+jttK4Yj2yY+2ldgCoxJkrP1NHUhjypsJ+eQXlGJ/jebM3wa60s1rbQ==} + hasBin: true + + fastq@1.18.0: + resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fixpack@4.0.0: + resolution: {integrity: sha512-5SM1+H2CcuJ3gGEwTiVo/+nd/hYpNj9Ch3iMDOQ58ndY+VGQ2QdvaUTkd3otjZvYnd/8LF/HkJ5cx7PBq0orCQ==} + hasBin: true + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreachasync@3.0.0: + resolution: {integrity: sha512-J+ler7Ta54FwwNcx6wQRDhTIbNeyDcARMkOcguEqnEdtm0jKvN3Li3PDAb2Du3ubJYEWfYL83XMROXdsXAXycw==} + + foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + + fork-ts-checker-webpack-plugin@9.0.2: + resolution: {integrity: sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==} + engines: {node: '>=12.13.0', yarn: '>=1.0.0'} + peerDependencies: + typescript: '>3.6.0' + webpack: ^5.11.0 + + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@3.0.3: + resolution: {integrity: sha512-q5YBMeWy6E2Un0nMGWMgI65MAKtaylxfNJGJxpGh45YDciZB4epbWpaAfImil6CPAPTYB4sh0URQNDRIZG5F2w==} + engines: {node: '>= 6'} + + form-data@4.0.3: + resolution: {integrity: sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + + formidable@2.1.2: + resolution: {integrity: sha512-CM3GuJ57US06mlpQ47YcunuUZ9jpm8Vx+P2CGt2j7HpgkKZO/DJYQ0Bobim8G6PFQmK5lOqOOdUXboU+h73A4g==} + + formidable@3.5.2: + resolution: {integrity: sha512-Jqc1btCy3QzRbJaICGwKcBfGWuLADRerLzDqi2NwSt/UkXLsHJw2TVResiaoBufHVHy9aSgClOHCeJsSsFLTbg==} + + formstream@1.5.1: + resolution: {integrity: sha512-q7ORzFqotpwn3Y/GBK2lK7PjtZZwJHz9QE9Phv8zb5IrL9ftGLyi2zjGURON3voK8TaZ+mqJKERYN4lrHYTkUQ==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-monkey@1.0.6: + resolution: {integrity: sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fs@0.0.1-security: + resolution: {integrity: sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.2.7: + resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-ready@1.0.0: + resolution: {integrity: sha512-mFXCZPJIlcYcth+N8267+mghfYN9h3EhsDa6JSnbA3Wrhh/XFpuowviFcsDeYZtKspQyWyJqfs4O6P8CHeTwzw==} + + get-stream@3.0.0: + resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} + engines: {node: '>=4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + google-auth-library@9.15.1: + resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} + engines: {node: '>=14'} + + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + + googleapis-common@7.2.0: + resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==} + engines: {node: '>=14.0.0'} + + googleapis@148.0.0: + resolution: {integrity: sha512-8PDG5VItm6E1TdZWDqtRrUJSlBcNwz0/MwCa6AL81y/RxPGXJRUwKqGZfCoVX1ZBbfr3I4NkDxBmeTyOAZSWqw==} + engines: {node: '>=14.0.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gtoken@7.1.0: + resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==} + engines: {node: '>=14.0.0'} + + handlebars@4.7.7: + resolution: {integrity: sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==} + engines: {node: '>=0.4.7'} + hasBin: true + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-own-prop@2.0.0: + resolution: {integrity: sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hbs@4.2.0: + resolution: {integrity: sha512-dQwHnrfWlTk5PvG9+a45GYpg0VpX47ryKF8dULVd6DtwOE6TEcYQXQ5QM6nyOx/h7v3bvEQbdn19EDAcfUAgZg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hexoid@1.0.0: + resolution: {integrity: sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==} + engines: {node: '>=8'} + + hexoid@2.0.0: + resolution: {integrity: sha512-qlspKUK7IlSQv2o+5I7yhUd7TxlOG2Vr5LTa3ve2XSNVKAL/n/u/7KLvKmFNimomDIKvZFXWHv0T12mv7rT8Aw==} + engines: {node: '>=8'} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + html-minifier@4.0.0: + resolution: {integrity: sha512-aoGxanpFPLg7MkIl/DDFYtb0iWz7jMFGqFhvEDZga6/4QTjneiD8I/NXL1x5aaoCp7FSIT6h/OhykDdPsbtMig==} + engines: {node: '>=6'} + hasBin: true + + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + + htmlparser2@5.0.1: + resolution: {integrity: sha512-vKZZra6CSe9qsJzh0BjBGXo8dvzNsq/oGvsjfRdOrrryfeD9UOBEEQdeoqCRmKZchF5h2zOBMQ6YuQ0uRUmdbQ==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + + http-server@14.1.1: + resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} + engines: {node: '>=12'} + hasBin: true + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + httpx@2.3.3: + resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + inquirer@8.2.6: + resolution: {integrity: sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==} + engines: {node: '>=12.0.0'} + + inquirer@9.2.15: + resolution: {integrity: sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==} + engines: {node: '>=18'} + + int64-buffer@0.99.1007: + resolution: {integrity: sha512-XDBEu44oSTqlvCSiOZ/0FoUkpWu/vwjJLGSKDabNISPQNZ5wub1FodGHBljRsrR0IXRPq7SslshZYMuA55CgTQ==} + engines: {node: '>= 4.5.0'} + + ioredis@5.4.2: + resolution: {integrity: sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==} + engines: {node: '>=12.22.0'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-class-hotfix@0.0.6: + resolution: {integrity: sha512-0n+pzCC6ICtVr/WXnN2f03TK/3BfXY7me4cjCAqT8TYXEl0+JBRoqBo94JJHXcyDSLUeWbNX8Fvy5g5RJdAstQ==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-stream@1.1.0: + resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-type-of@1.4.0: + resolution: {integrity: sha512-EddYllaovi5ysMLMEN7yzHEKh8A850cZ7pykrY1aNRQGn/CDjRDE9qEWbIdt7xGEVJmjBXzU/fNnC4ABTm8tEQ==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + + istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + + iterare@1.2.1: + resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==} + engines: {node: '>=6'} + + jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.2: + resolution: {integrity: sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==} + engines: {node: '>=10'} + hasBin: true + + jest-changed-files@29.7.0: + resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-circus@29.7.0: + resolution: {integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-cli@29.7.0: + resolution: {integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: {integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: {integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-docblock@29.7.0: + resolution: {integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-each@29.7.0: + resolution: {integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-haste-map@29.7.0: + resolution: {integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-leak-detector@29.7.0: + resolution: {integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-matcher-utils@29.7.0: + resolution: {integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: {integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve-dependencies@29.7.0: + resolution: {integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-resolve@29.7.0: + resolution: {integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runner@29.7.0: + resolution: {integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-runtime@29.7.0: + resolution: {integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-snapshot@29.7.0: + resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-watcher@29.7.0: + resolution: {integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest@29.7.0: + resolution: {integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-base64@2.6.4: + resolution: {integrity: sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-bigint@0.2.3: + resolution: {integrity: sha512-pG8elXWCTAIwH1W8FwjDbj2FBJSi2WE5PdV0dm+c+7LAmH6XL6fwDsdQGgAgOZljcF3Kj9Uzop2TfGfPDSOUqA==} + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonc-parser@3.2.1: + resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + + jstoxml@2.2.9: + resolution: {integrity: sha512-OYWlK0j+roh+eyaMROlNbS5cd5R25Y+IUpdl7cNdB8HNrkgwQzIS7L9MegxOiWNBj9dQhA/yAxiMwCC5mwNoBw==} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + + juice@10.0.1: + resolution: {integrity: sha512-ZhJT1soxJCkOiO55/mz8yeBKTAJhRzX9WBO+16ZTqNTONnnVlUPyVBIzQ7lDRjaBdTbid+bAnyIon/GM3yp4cA==} + engines: {node: '>=10.0.0'} + hasBin: true + + jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + + jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + + kareem@2.6.3: + resolution: {integrity: sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==} + engines: {node: '>=12.0.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kitx@1.3.0: + resolution: {integrity: sha512-fhBqFlXd0GkKTB+8ayLfpzPUw+LHxZlPAukPNBD1Om7JMeInT+/PxCAf1yLagvD+VKoyWhXtJR68xQkX/a0wOQ==} + + kitx@2.2.0: + resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.6: + resolution: {integrity: sha512-j9mBC7eiqi6fgBPAGvKCXJKJSIASanYF4EeA4iBzSG0HxQxmXnR3KbyWqTn4CwsKSebqCv2f5XZfAO6sKzgvwA==} + + libphonenumber-js@1.11.17: + resolution: {integrity: sha512-Jr6v8thd5qRlOlc6CslSTzGzzQW03uiscab7KHQZX1Dfo4R6n6FDhZ0Hri6/X7edLIDv9gl4VMZXhxTjLnl0VQ==} + + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + liquidjs@10.21.1: + resolution: {integrity: sha512-NZXmCwv3RG5nire3fmIn9HsOyJX3vo+ptp0yaXUHAMzSNBhx74Hm+dAGJvscUA6lNqbLuYfXgNavRQ9UbUJhQQ==} + engines: {node: '>=14'} + hasBin: true + + loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lower-case@1.1.4: + resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + + mailparser@3.7.3: + resolution: {integrity: sha512-0RM14cZF0gO1y2Q/82hhWranispZOUSYHwvQ21h12x90NwD6+D5q59S5nOLqCtCdYitHN58LJXWEHa4RWm7BYA==} + + mailsplit@5.4.3: + resolution: {integrity: sha512-PFV0BBh4Tv7Omui5FtXXVtN4ExAxIi8Yvmb9JgBz+J6Hnnrv/YYXLlKKudLhXwd3/qWEATOslRsnzVCWDeCnmQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + memfs@3.5.3: + resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} + engines: {node: '>= 4.0.0'} + + memjs@1.3.2: + resolution: {integrity: sha512-qUEg2g8vxPe+zPn09KidjIStHPtoBO8Cttm8bgJFWWabbsjQ9Av9Ky+6UcvKx6ue0LLb/LEhtcyQpRyKfzeXcg==} + engines: {node: '>=0.10.0'} + + memory-pager@1.5.0: + resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + + mensch@0.3.4: + resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.1: + resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mjml-accordion@4.15.3: + resolution: {integrity: sha512-LPNVSj1LyUVYT9G1gWwSw3GSuDzDsQCu0tPB2uDsq4VesYNnU6v3iLCQidMiR6azmIt13OEozG700ygAUuA6Ng==} + + mjml-body@4.15.3: + resolution: {integrity: sha512-7pfUOVPtmb0wC+oUOn4xBsAw4eT5DyD6xqaxj/kssu6RrFXOXgJaVnDPAI9AzIvXJ/5as9QrqRGYAddehwWpHQ==} + + mjml-button@4.15.3: + resolution: {integrity: sha512-79qwn9AgdGjJR1vLnrcm2rq2AsAZkKC5JPwffTMG+Nja6zGYpTDZFZ56ekHWr/r1b5WxkukcPj2PdevUug8c+Q==} + + mjml-carousel@4.15.3: + resolution: {integrity: sha512-3ju6I4l7uUhPRrJfN3yK9AMsfHvrYbRkcJ1GRphFHzUj37B2J6qJOQUpzA547Y4aeh69TSb7HFVf1t12ejQxVw==} + + mjml-cli@4.15.3: + resolution: {integrity: sha512-+V2TDw3tXUVEptFvLSerz125C2ogYl8klIBRY1m5BHd4JvGVf3yhx8N3PngByCzA6PGcv/eydGQN+wy34SHf0Q==} + hasBin: true + + mjml-column@4.15.3: + resolution: {integrity: sha512-hYdEFdJGHPbZJSEysykrevEbB07yhJGSwfDZEYDSbhQQFjV2tXrEgYcFD5EneMaowjb55e3divSJxU4c5q4Qgw==} + + mjml-core@4.15.3: + resolution: {integrity: sha512-Dmwk+2cgSD9L9GmTbEUNd8QxkTZtW9P7FN/ROZW/fGZD6Hq6/4TB0zEspg2Ow9eYjZXO2ofOJ3PaQEEShKV0kQ==} + + mjml-divider@4.15.3: + resolution: {integrity: sha512-vh27LQ9FG/01y0b9ntfqm+GT5AjJnDSDY9hilss2ixIUh0FemvfGRfsGVeV5UBVPBKK7Ffhvfqc7Rciob9Spzw==} + + mjml-group@4.15.3: + resolution: {integrity: sha512-HSu/rKnGZVKFq3ciT46vi1EOy+9mkB0HewO4+P6dP/Y0UerWkN6S3UK11Cxsj0cAp0vFwkPDCdOeEzRdpFEkzA==} + + mjml-head-attributes@4.15.3: + resolution: {integrity: sha512-2ISo0r5ZKwkrvJgDou9xVPxxtXMaETe2AsAA02L89LnbB2KC0N5myNsHV0sEysTw9+CfCmgjAb0GAI5QGpxKkQ==} + + mjml-head-breakpoint@4.15.3: + resolution: {integrity: sha512-Eo56FA5C2v6ucmWQL/JBJ2z641pLOom4k0wP6CMZI2utfyiJ+e2Uuinj1KTrgDcEvW4EtU9HrfAqLK9UosLZlg==} + + mjml-head-font@4.15.3: + resolution: {integrity: sha512-CzV2aDPpiNIIgGPHNcBhgyedKY4SX3BJoTwOobSwZVIlEA6TAWB4Z9WwFUmQqZOgo1AkkiTHPZQvGcEhFFXH6g==} + + mjml-head-html-attributes@4.15.3: + resolution: {integrity: sha512-MDNDPMBOgXUZYdxhosyrA2kudiGO8aogT0/cODyi2Ed9o/1S7W+je11JUYskQbncqhWKGxNyaP4VWa+6+vUC/g==} + + mjml-head-preview@4.15.3: + resolution: {integrity: sha512-J2PxCefUVeFwsAExhrKo4lwxDevc5aKj888HBl/wN4EuWOoOg06iOGCxz4Omd8dqyFsrqvbBuPqRzQ+VycGmaA==} + + mjml-head-style@4.15.3: + resolution: {integrity: sha512-9J+JuH+mKrQU65CaJ4KZegACUgNIlYmWQYx3VOBR/tyz+8kDYX7xBhKJCjQ1I4wj2Tvga3bykd89Oc2kFZ5WOw==} + + mjml-head-title@4.15.3: + resolution: {integrity: sha512-IM59xRtsxID4DubQ0iLmoCGXguEe+9BFG4z6y2xQDrscIa4QY3KlfqgKGT69ojW+AVbXXJPEVqrAi4/eCsLItQ==} + + mjml-head@4.15.3: + resolution: {integrity: sha512-o3mRuuP/MB5fZycjD3KH/uXsnaPl7Oo8GtdbJTKtH1+O/3pz8GzGMkscTKa97l03DAG2EhGrzzLcU2A6eshwFw==} + + mjml-hero@4.15.3: + resolution: {integrity: sha512-9cLAPuc69yiuzNrMZIN58j+HMK1UWPaq2i3/Fg2ZpimfcGFKRcPGCbEVh0v+Pb6/J0+kf8yIO0leH20opu3AyQ==} + + mjml-image@4.15.3: + resolution: {integrity: sha512-g1OhSdofIytE9qaOGdTPmRIp7JsCtgO0zbsn1Fk6wQh2gEL55Z40j/VoghslWAWTgT2OHFdBKnMvWtN6U5+d2Q==} + + mjml-migrate@4.15.3: + resolution: {integrity: sha512-sr/+35RdxZroNQVegjpfRHJ5hda9XCgaS4mK2FGO+Mb1IUevKfeEPII3F/cHDpNwFeYH3kAgyqQ22ClhGLWNBA==} + hasBin: true + + mjml-navbar@4.15.3: + resolution: {integrity: sha512-VsKH/Jdlf8Yu3y7GpzQV5n7JMdpqvZvTSpF6UQXL0PWOm7k6+LX+sCZimOfpHJ+wCaaybpxokjWZ71mxOoCWoA==} + + mjml-parser-xml@4.15.3: + resolution: {integrity: sha512-Tz0UX8/JVYICLjT+U8J1f/TFxIYVYjzZHeh4/Oyta0pLpRLeZlxEd71f3u3kdnulCKMP4i37pFRDmyLXAlEuLw==} + + mjml-preset-core@4.15.3: + resolution: {integrity: sha512-1zZS8P4O0KweWUqNS655+oNnVMPQ1Rq1GaZq5S9JfwT1Vh/m516lSmiTW9oko6gGHytt5s6Yj6oOeu5Zm8FoLw==} + + mjml-raw@4.15.3: + resolution: {integrity: sha512-IGyHheOYyRchBLiAEgw3UM11kFNmBSMupu2BDdejC6ZiDhEAdG+tyERlsCwDPYtXanvFpGWULIu3XlsUPc+RZw==} + + mjml-section@4.15.3: + resolution: {integrity: sha512-JfVPRXH++Hd933gmQfG8JXXCBCR6fIzC3DwiYycvanL/aW1cEQ2EnebUfQkt5QzlYjOkJEH+JpccAsq3ln6FZQ==} + + mjml-social@4.15.3: + resolution: {integrity: sha512-7sD5FXrESOxpT9Z4Oh36bS6u/geuUrMP1aCg2sjyAwbPcF1aWa2k9OcatQfpRf6pJEhUZ18y6/WBBXmMVmSzXg==} + + mjml-spacer@4.15.3: + resolution: {integrity: sha512-3B7Qj+17EgDdAtZ3NAdMyOwLTX1jfmJuY7gjyhS2HtcZAmppW+cxqHUBwCKfvSRgTQiccmEvtNxaQK+tfyrZqA==} + + mjml-table@4.15.3: + resolution: {integrity: sha512-FLx7DcRKTdKdcOCbMyBaeudeHaHpwPveRrBm6WyQe3LXx6FfdmOh59i71/16LFQMgBOD3N4/UJkzxLzlTJzMqQ==} + + mjml-text@4.15.3: + resolution: {integrity: sha512-+C0hxCmw9kg0XzT6vhE5mFkK6y225nC8UEQcN94K0fBCjPKkM+HqZMwGX205fzdGRi+Bxa55b/VhrIVwdv+8vw==} + + mjml-validator@4.15.3: + resolution: {integrity: sha512-Xb72KdqRwjv/qM2rJpV22syyP2N3cRQ9VVDrN6u2FSzLq02buFNxmSPJ7CKhat3PrUNdVHU75KZwOf/tz4UEhA==} + + mjml-wrapper@4.15.3: + resolution: {integrity: sha512-ditsCijeHJrmBmObtJmQ18ddLxv5oPyMTdPU8Di8APOnD2zPk7Z4UAuJSl7HXB45oFiivr3MJf4koFzMUSZ6Gg==} + + mjml@4.15.3: + resolution: {integrity: sha512-bW2WpJxm6HS+S3Yu6tq1DUPFoTxU9sPviUSmnL7Ua+oVO3WA5ILFWqvujUlz+oeuM+HCwEyMiP5xvKNPENVjYA==} + hasBin: true + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + moment-timezone@0.5.46: + resolution: {integrity: sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==} + + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + mongodb-connection-string-url@3.0.1: + resolution: {integrity: sha512-XqMGwRX0Lgn05TDB4PyG2h2kKO/FfWJyCzYQbIhXUxz7ETt0I/FqHjUeqj37irJ+Dl1ZtU82uYyj14u2XsZKfg==} + + mongodb@6.12.0: + resolution: {integrity: sha512-RM7AHlvYfS7jv7+BXund/kR64DryVI+cHbVAy9P61fnb1RcWZqOW1/Wj2YhqMCx+MuYhqTRGv7AwHBzmsCKBfA==} + engines: {node: '>=16.20.1'} + peerDependencies: + '@aws-sdk/credential-providers': ^3.188.0 + '@mongodb-js/zstd': ^1.1.0 || ^2.0.0 + gcp-metadata: ^5.2.0 + kerberos: ^2.0.1 + mongodb-client-encryption: '>=6.0.0 <7' + snappy: ^7.2.2 + socks: ^2.7.1 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@8.9.4: + resolution: {integrity: sha512-DndoI01aV/q40P9DiYDXsYjhj8vZjmmuFwcC3Tro5wFznoE1z6Fe2JgMnbLR6ghglym5ziYizSfAJykp+UPZWg==} + engines: {node: '>=16.20.1'} + + mpath@0.9.0: + resolution: {integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==} + engines: {node: '>=4.0.0'} + + mquery@5.0.0: + resolution: {integrity: sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==} + engines: {node: '>=14.0.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.3: + resolution: {integrity: sha512-mNdO4s/W54QCghwGNSqO5ULVJ6QUimP/1hRlWVx5f7frTLaClg+4sBRjUTgP1OrBRgVtkH1tI9vi4Dqg/JX3Kg==} + + multer@1.4.4-lts.1: + resolution: {integrity: sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==} + engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + + multer@2.0.0: + resolution: {integrity: sha512-bS8rPZurbAuHGAnApbM9d4h1wSoYqrOqkE+6a64KLMK9yWU7gJXBDDVklKQ3TPi9DRb85cRs6yXaC0+cjxRtRg==} + engines: {node: '>= 10.16.0'} + + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + + mute-stream@1.0.0: + resolution: {integrity: sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nan@2.22.0: + resolution: {integrity: sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==} + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + natural@8.0.1: + resolution: {integrity: sha512-VVw8O5KrfvwqAFeNZEgBbdgA+AQaBlHcXEootWU7TWDaFWFI0VLfzyKMsRjnfdS3cVCpWmI04xLJonCvEv11VQ==} + engines: {node: '>=0.4.10'} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + nest-wechatpay-node-v3@1.0.2: + resolution: {integrity: sha512-FKZq30BT1w0xMRe0CbJtxWOhsvoHjXcxd2B4HjGQzkgyiuBUDWsxy6ZipDerDR9elVF11j9RGUhpz7c+Sd7gTw==} + + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + + no-case@2.3.2: + resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-emoji@1.11.0: + resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + + node-hex@1.0.1: + resolution: {integrity: sha512-iwpZdvW6Umz12ICmu9IYPRxg0tOLGmU3Tq2tKetejCj3oZd7b2nUXwP3a7QA5M9glWy8wlPS1G3RwM/CdsUbdQ==} + engines: {node: '>=8.0.0'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + nodemailer@6.10.1: + resolution: {integrity: sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==} + engines: {node: '>=6.0.0'} + + nodemailer@7.0.3: + resolution: {integrity: sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==} + engines: {node: '>=6.0.0'} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@2.0.2: + resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + engines: {node: '>=4'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.3: + resolution: {integrity: sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + + openai@4.96.0: + resolution: {integrity: sha512-dKoW56i02Prv2XQolJ9Rl9Svqubqkzg3QpwEOBuSVZLk05Shelu7s+ErRTwFc1Bs3JZ2qBqBfVpXQiJhwOGG8A==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + os-name@1.0.3: + resolution: {integrity: sha512-f5estLO2KN8vgtTRaILIgEGBoBrMnZ3JQ7W9TMZCnOIGwHe8TRGSpcagnWDo+Dfhd/z08k9Xe75hvciJJ8Qaew==} + engines: {node: '>=0.10.0'} + hasBin: true + + os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + osx-release@1.1.0: + resolution: {integrity: sha512-ixCMMwnVxyHFQLQnINhmIpWqXIfS2YOXchwQrk+OFzmo6nDjQ0E4KXAyyUh0T0MZgV4bUhkRrAbVqlE4yLVq4A==} + engines: {node: '>=0.10.0'} + hasBin: true + + p-event@4.2.0: + resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} + engines: {node: '>=8'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + p-wait-for@3.2.0: + resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + param-case@2.1.1: + resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@2.0.1: + resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-to-regexp@3.3.0: + resolution: {integrity: sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==} + + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + path@0.12.7: + resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==} + + pause-stream@0.0.11: + resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + + pg-cloudflare@1.2.5: + resolution: {integrity: sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==} + + pg-connection-string@2.9.0: + resolution: {integrity: sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.10.0: + resolution: {integrity: sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.10.0: + resolution: {integrity: sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.16.0: + resolution: {integrity: sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==} + engines: {node: '>= 8.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.1: + resolution: {integrity: sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==} + engines: {node: '>=12'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + portfinder@1.0.37: + resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==} + engines: {node: '>= 10.12'} + + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.0: + resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.0: + resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} + engines: {node: '>=6.0.0'} + + prettier@3.4.2: + resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + preview-email@3.1.0: + resolution: {integrity: sha512-ZtV1YrwscEjlrUzYrTSs6Nwo49JM3pXLM4fFOBSC3wSni+bxaWlw9/Qgk75PZO8M7cX2EybmL2iwvaV3vkAttw==} + engines: {node: '>=14'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + properties-reader@2.3.0: + resolution: {integrity: sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw==} + engines: {node: '>=14'} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.3: + resolution: {integrity: sha512-cYQg0JW0w32Ux+XTeZnBEeuWrAY7/HNE6TWnhiHGnnRYlCgyAUPoyh9KzCMa9WhcJlJ1AtQqpEYHc+vbCzA+Aw==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.3: + resolution: {integrity: sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g==} + + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + queue-tick@1.0.1: + resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==} + + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + + randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + + raw-body@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + relateurl@0.2.7: + resolution: {integrity: sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==} + engines: {node: '>= 0.10'} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@3.2.0: + resolution: {integrity: sha512-Ep0RsvAjnRcBX1p5vogbaBdAGu/8j/ewpvGqnQYunnLd9SM0vWcPJewPKNnWFggf0hF0pwIgwV5XK7qQ7UZ8Qg==} + engines: {node: '>=4'} + + run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + + run-async@3.0.0: + resolution: {integrity: sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==} + engines: {node: '>=0.12.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} + + schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + + schema-utils@4.3.0: + resolution: {integrity: sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g==} + engines: {node: '>= 10.13.0'} + + sdk-base@2.0.1: + resolution: {integrity: sha512-eeG26wRwhtwYuKGCDM3LixCaxY27Pa/5lK4rLKhQa7HBjJ3U3Y+f81MMZQRsDw/8SC2Dao/83yJTXJ8aULuN8Q==} + + secure-compare@3.0.1: + resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + + semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@1.2.0: + resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@1.0.0: + resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} + engines: {node: '>=0.10.0'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + sift@17.1.3: + resolution: {integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slick@1.12.2: + resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + + sm3@1.0.3: + resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + + sparse-bitfield@3.0.3: + resolution: {integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==} + + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.16.0: + resolution: {integrity: sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==} + engines: {node: '>=10.16.0'} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + stopwords-iso@1.1.0: + resolution: {integrity: sha512-I6GPS/E0zyieHehMRPQcqkiBMJKGgLta+1hREixhoLPqEA0AlVFiC43dl8uPpmkkeRdDMzYRWFWk5/l9x7nmNg==} + engines: {node: '>=0.10.0'} + + stream-http@2.8.2: + resolution: {integrity: sha512-QllfrBhqF1DPcz46WxKTs6Mz1Bpc+8Qm6vbqOpVav5odAXwbyzwnEczoWqtxrsmlO+cJqtPrp/8gWKWjaKLLlA==} + + stream-wormhole@1.1.0: + resolution: {integrity: sha512-gHFfL3px0Kctd6Po0M8TzEvt3De/xu6cnRrjlfYNhwbhLPLwigI2t1nc6jrzNuaYg5C4YF78PPFuQPzRiqn9ew==} + engines: {node: '>=4.0.0'} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + streamx@2.21.1: + resolution: {integrity: sha512-PhP9wUnFLa+91CPy3N6tiQsK+gnYyUNuk15S3YG/zjYE7RuPeCjJngqnzpC31ow0lzBHQ+QGO4cNJnd0djYUsw==} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-eof@1.0.0: + resolution: {integrity: sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==} + engines: {node: '>=0.10.0'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strnum@2.0.5: + resolution: {integrity: sha512-YAT3K/sgpCUxhxNMrrdhtod3jckkpYwH6JAuwmUdXZsmzH1wUyzTMrrK2wYCEEqlKwrWDd35NeuUkbBy/1iK+Q==} + + superagent@8.0.6: + resolution: {integrity: sha512-HqSe6DSIh3hEn6cJvCkaM1BLi466f1LHi4yubR0tpewlMpk4RUFFy35bKz8SsPBwYfIIJy5eclp+3tCYAuX0bw==} + engines: {node: '>=6.4.0 <13 || >=14'} + deprecated: Please upgrade to v9.0.0+ as we have fixed a public vulnerability with formidable dependency. Note that v9.0.0+ requires Node.js v14.18.0+. See https://github.com/ladjs/superagent/pull/1800 for insight. This project is supported and maintained by the team at Forward Email @ https://forwardemail.net + + superagent@9.0.2: + resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} + engines: {node: '>=14.18.0'} + + supertest@7.0.0: + resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} + engines: {node: '>=14.18.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + swagger-ui-dist@5.18.2: + resolution: {integrity: sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + + sylvester@0.0.12: + resolution: {integrity: sha512-SzRP5LQ6Ts2G5NyAa/jg16s8e3R7rfdFjizy1zeoecYWw+nGL+YA1xZvW/+iJmidBGSdLkuvdwTYEyJEb+EiUw==} + engines: {node: '>=0.2.6'} + + symbol-observable@4.0.0: + resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} + engines: {node: '>=0.10'} + + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + engines: {node: ^14.18.0 || >=16.0.0} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar-fs@2.0.1: + resolution: {integrity: sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==} + + tar-fs@3.0.8: + resolution: {integrity: sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tencentcloud-sdk-nodejs-common@4.1.1: + resolution: {integrity: sha512-ipUKFgaAHwAFRI5MiWGvKAle0dilXJ/EzVoJNvOg2FTHSjtPbD1kbaOWuNKG2l1v3C1NtMMSbRRaWFi3HQWz3w==} + engines: {node: '>=10'} + + tencentcloud-sdk-nodejs-tms@4.0.1052: + resolution: {integrity: sha512-BUZMff+ww4sfIRRSpYUtATZXdDhcssCPFsU4NYACqXTWhJ4CneSeVi0LzogYxovZT5VKNlqWdWrfzdluWb4P9Q==} + engines: {node: '>=10'} + + terser-webpack-plugin@5.3.11: + resolution: {integrity: sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + + terser@5.37.0: + resolution: {integrity: sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + testcontainers@10.16.0: + resolution: {integrity: sha512-oxPLuOtrRWS11A+Yn0+zXB7GkmNarflWqmy6CQJk8KJ75LZs2/zlUXDpizTbPpCGtk4kE2EQYwFZjrE967F8Wg==} + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + + tlds@1.259.0: + resolution: {integrity: sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==} + hasBin: true + + tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + + tmp@0.2.3: + resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} + engines: {node: '>=14.14'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-arraybuffer@1.0.1: + resolution: {integrity: sha512-okFlQcoGTi4LQBG/PgSYblw9VOyptsz2KJZqc6qtgGdes8VktzUQkj4BI2blit072iS8VODNcMA+tvnS9dnuMA==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.0.0: + resolution: {integrity: sha512-xCt/TOAc+EOHS1XPnijD3/yzpH6qg2xppZO1YDqGoVsNXfQfzHpOdNuXwrwOU8u4ITXJyDCTyt8w5g1sZv9ynQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.2.5: + resolution: {integrity: sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 + '@jest/types': ^29.0.0 + babel-jest: ^29.0.0 + esbuild: '*' + jest: ^29.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + + ts-loader@9.5.2: + resolution: {integrity: sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==} + engines: {node: '>=12.0.0'} + peerDependencies: + typescript: '*' + webpack: ^5.0.0 + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + 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 + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@1.13.0: + resolution: {integrity: sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.7.2: + resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + + uid@2.0.2: + resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} + engines: {node: '>=8'} + + underscore@1.13.7: + resolution: {integrity: sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==} + + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici-types@6.20.0: + resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + + undici@5.28.4: + resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + engines: {node: '>=14.0'} + + undici@6.21.3: + resolution: {integrity: sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==} + engines: {node: '>=18.17'} + + unescape@1.0.1: + resolution: {integrity: sha512-O0+af1Gs50lyH1nUu3ZyYS1cRh01Q/kUKatTOkSs7jukXE6/NebucDVxyiDsA9AQ4JC1V1jUH9EO8JX2nMDgGQ==} + engines: {node: '>=0.10.0'} + + union@0.5.0: + resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} + engines: {node: '>= 0.8.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.2: + resolution: {integrity: sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + upper-case@1.1.3: + resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + + urllib@2.44.0: + resolution: {integrity: sha512-zRCJqdfYllRDA9bXUtx+vccyRqtJPKsw85f44zH7zPD28PIvjMqIgw9VwoTLV7xTBWZsbebUFVHU5ghQcWku2A==} + engines: {node: '>= 0.10.0'} + peerDependencies: + proxy-agent: ^5.0.0 + peerDependenciesMeta: + proxy-agent: + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.10.4: + resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==} + + utility@1.18.0: + resolution: {integrity: sha512-PYxZDA+6QtvRvm//++aGdmKG/cI07jNwbROz0Ql+VzFV1+Z0Dy55NI4zZ7RHc9KKpBePNFwoErqIuqQv/cjiTA==} + engines: {node: '>= 0.12.0'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + + uuid@11.0.5: + resolution: {integrity: sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==} + hasBin: true + + uuid@3.4.0: + resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==} + deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details. + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + valid-data-url@3.0.1: + resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} + engines: {node: '>=10'} + + validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + walk@2.3.15: + resolution: {integrity: sha512-4eRTBZljBfIISK1Vnt69Gvr2w/wc3U6Vtrw7qiN5iqYJPH7LElcYh/iU4XWhdCy2dZqv1ToMyYlybDylfG/5Vg==} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + watchpack@2.4.2: + resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} + engines: {node: '>=10.13.0'} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-resource-inliner@6.0.1: + resolution: {integrity: sha512-kfqDxt5dTB1JhqsCUQVFDj0rmY+4HLwGQIsLPbyrsN9y9WV/1oFDSx3BQ4GfCv9X+jVeQ7rouTqwK53rA/7t8A==} + engines: {node: '>=10.0.0'} + + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webpack-node-externals@3.0.0: + resolution: {integrity: sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==} + engines: {node: '>=6'} + + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + + webpack@5.97.1: + resolution: {integrity: sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + wechatpay-node-v3@2.2.1: + resolution: {integrity: sha512-z+n8Mrzn0UNoLJPBRrY8ZG6yo9xxNihlGvwvAbV8Nlnm4tTap2UjwIikGkhryC8gOmwrlvJfSUd+x1cK3ks1hA==} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@13.0.0: + resolution: {integrity: sha512-9WWbymnqj57+XEuqADHrCJ2eSXzn8WXIW/YSGaZtb2WKAInQ6CHfaUUcTyyver0p8BDg5StLQq8h1vtZuwmOig==} + engines: {node: '>=16'} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + win-release@1.1.1: + resolution: {integrity: sha512-iCRnKVvGxOQdsKhcQId2PXV1vV3J/sDPXKA4Oe9+Eti2nb2ESEsYHRYls/UjoUW3bIc5ZDO8dTH50A/5iVN+bw==} + engines: {node: '>=0.10.0'} + + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordnet-db@3.1.14: + resolution: {integrity: sha512-zVyFsvE+mq9MCmwXUWHIcpfbrHHClZWZiVOzKSxNJruIcFn2RbY55zkhiAMMxM8zCVSmtNiViq8FsAZSFpMYag==} + engines: {node: '>=0.6.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + xml-js@1.6.11: + resolution: {integrity: sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==} + hasBin: true + + xml2js@0.4.23: + resolution: {integrity: sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==} + engines: {node: '>=4.0.0'} + + xml2js@0.6.2: + resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==} + engines: {node: '>=4.0.0'} + + xmlbuilder@11.0.1: + resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} + engines: {node: '>=4.0'} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@2.7.0: + resolution: {integrity: sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==} + engines: {node: '>= 14'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + +snapshots: + + '@alicloud/cloudauth20190307@3.5.0': + dependencies: + '@alicloud/openapi-core': 1.0.2 + '@alicloud/openplatform20191219': 2.0.0 + '@alicloud/oss-client': 1.1.4 + '@alicloud/oss-util': 0.0.1 + '@alicloud/tea-fileform': 1.2.0 + '@darabonba/typescript': 1.0.2 + transitivePeerDependencies: + - supports-color + + '@alicloud/credentials@1.1.0': + dependencies: + '@alicloud/sts-sdk': 1.0.2 + httpx: 2.3.3 + ini: 1.3.8 + json-bigint: 0.2.3 + kitx: 1.3.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/credentials@2.4.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + httpx: 2.3.3 + ini: 1.3.8 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-array@0.1.0': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-encode-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + moment: 2.30.1 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-encode-util@0.0.2': + dependencies: + moment: 2.30.1 + + '@alicloud/darabonba-map@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-signature-util@0.0.4': + dependencies: + '@alicloud/darabonba-encode-util': 0.0.1 + transitivePeerDependencies: + - supports-color + + '@alicloud/darabonba-string@1.0.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/dypnsapi20170525@1.2.3': + dependencies: + '@alicloud/endpoint-util': 0.0.1 + '@alicloud/openapi-client': 0.4.12 + '@alicloud/openapi-util': 0.3.2 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + transitivePeerDependencies: + - supports-color + + '@alicloud/dysmsapi20170525@3.1.1': + dependencies: + '@alicloud/endpoint-util': 0.0.1 + '@alicloud/openapi-client': 0.4.12 + '@alicloud/openapi-util': 0.3.2 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + transitivePeerDependencies: + - supports-color + + '@alicloud/endpoint-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/gateway-pop@0.0.6': + dependencies: + '@alicloud/credentials': 2.4.3 + '@alicloud/darabonba-array': 0.1.0 + '@alicloud/darabonba-encode-util': 0.0.2 + '@alicloud/darabonba-map': 0.0.1 + '@alicloud/darabonba-signature-util': 0.0.4 + '@alicloud/darabonba-string': 1.0.3 + '@alicloud/endpoint-util': 0.0.1 + '@alicloud/gateway-spi': 0.0.8 + '@alicloud/openapi-util': 0.3.2 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + transitivePeerDependencies: + - supports-color + + '@alicloud/gateway-spi@0.0.8': + dependencies: + '@alicloud/credentials': 2.4.3 + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/http-core-sdk@1.0.0': + dependencies: + httpx: 2.3.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-client@0.4.12': + dependencies: + '@alicloud/credentials': 2.4.3 + '@alicloud/gateway-spi': 0.0.8 + '@alicloud/openapi-util': 0.3.2 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + '@alicloud/tea-xml': 0.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-core@1.0.2': + dependencies: + '@alicloud/gateway-pop': 0.0.6 + '@alicloud/gateway-spi': 0.0.8 + '@darabonba/typescript': 1.0.2 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-util@0.2.9': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + kitx: 2.2.0 + sm3: 1.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/openapi-util@0.3.2': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + kitx: 2.2.0 + sm3: 1.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/openplatform20191219@2.0.0': + dependencies: + '@alicloud/endpoint-util': 0.0.1 + '@alicloud/openapi-client': 0.4.12 + '@alicloud/openapi-util': 0.2.9 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.10 + transitivePeerDependencies: + - supports-color + + '@alicloud/oss-baseclient@1.2.0': + dependencies: + '@alicloud/credentials': 1.1.0 + '@alicloud/tea-typescript': 1.8.0 + '@types/mime': 2.0.3 + '@types/xml2js': 0.4.14 + int64-buffer: 0.99.1007 + kitx: 2.2.0 + mime: 2.6.0 + xml2js: 0.4.23 + transitivePeerDependencies: + - supports-color + + '@alicloud/oss-client@1.1.4': + dependencies: + '@alicloud/credentials': 2.4.3 + '@alicloud/oss-baseclient': 1.2.0 + '@alicloud/oss-util': 0.0.3 + '@alicloud/rpc-util': 0.0.1 + '@alicloud/tea-fileform': 1.2.0 + '@alicloud/tea-typescript': 1.8.0 + '@alicloud/tea-util': 1.4.9 + '@alicloud/tea-xml': 0.0.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/oss-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@types/mime': 2.0.3 + '@types/xml2js': 0.4.14 + int64-buffer: 0.99.1007 + kitx: 2.2.0 + mime: 2.6.0 + xml2js: 0.4.23 + transitivePeerDependencies: + - supports-color + + '@alicloud/oss-util@0.0.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@types/mime': 2.0.3 + '@types/xml2js': 0.4.14 + int64-buffer: 0.99.1007 + kitx: 2.2.0 + mime: 2.6.0 + xml2js: 0.4.23 + transitivePeerDependencies: + - supports-color + + '@alicloud/rpc-util@0.0.1': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@types/xml2js': 0.4.14 + kitx: 2.2.0 + xml2js: 0.4.23 + transitivePeerDependencies: + - supports-color + + '@alicloud/sts-sdk@1.0.2': + dependencies: + '@alicloud/http-core-sdk': 1.0.0 + uuid: 3.4.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-fileform@1.2.0': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-typescript@1.8.0': + dependencies: + '@types/node': 12.20.55 + httpx: 2.3.3 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-util@1.4.10': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@darabonba/typescript': 1.0.2 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-util@1.4.9': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + kitx: 2.2.0 + transitivePeerDependencies: + - supports-color + + '@alicloud/tea-xml@0.0.3': + dependencies: + '@alicloud/tea-typescript': 1.8.0 + '@types/xml2js': 0.4.14 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@angular-devkit/core@17.3.11(chokidar@3.6.0)': + dependencies: + ajv: 8.12.0 + ajv-formats: 2.1.1(ajv@8.12.0) + jsonc-parser: 3.2.1 + picomatch: 4.0.1 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 3.6.0 + + '@angular-devkit/schematics-cli@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + ansi-colors: 4.1.3 + inquirer: 9.2.15 + symbol-observable: 4.0.0 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - chokidar + + '@angular-devkit/schematics@17.3.11(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + jsonc-parser: 3.2.1 + magic-string: 0.30.8 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + + '@babel/code-frame@7.26.2': + dependencies: + '@babel/helper-validator-identifier': 7.25.9 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.26.5': {} + + '@babel/core@7.26.0': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/helper-compilation-targets': 7.26.5 + '@babel/helper-module-transforms': 7.26.0(@babel/core@7.26.0) + '@babel/helpers': 7.26.0 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.26.5': + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.26.5': + dependencies: + '@babel/compat-data': 7.26.5 + '@babel/helper-validator-option': 7.25.9 + browserslist: 4.24.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.25.9': + dependencies: + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-module-imports': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@babel/traverse': 7.26.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.26.5': {} + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/helper-validator-option@7.25.9': {} + + '@babel/helpers@7.26.0': + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + + '@babel/parser@7.26.5': + dependencies: + '@babel/types': 7.26.5 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-import-attributes@7.26.0(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/plugin-syntax-typescript@7.25.9(@babel/core@7.26.0)': + dependencies: + '@babel/core': 7.26.0 + '@babel/helper-plugin-utils': 7.26.5 + + '@babel/runtime@7.27.1': + optional: true + + '@babel/template@7.25.9': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + + '@babel/traverse@7.26.5': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.26.5': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@balena/dockerignore@1.0.2': {} + + '@bcoe/v8-coverage@0.2.3': {} + + '@colors/colors@1.5.0': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@css-inline/css-inline-android-arm-eabi@0.14.1': + optional: true + + '@css-inline/css-inline-android-arm64@0.14.1': + optional: true + + '@css-inline/css-inline-darwin-arm64@0.14.1': + optional: true + + '@css-inline/css-inline-darwin-x64@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm-gnueabihf@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm64-gnu@0.14.1': + optional: true + + '@css-inline/css-inline-linux-arm64-musl@0.14.1': + optional: true + + '@css-inline/css-inline-linux-x64-gnu@0.14.1': + optional: true + + '@css-inline/css-inline-linux-x64-musl@0.14.1': + optional: true + + '@css-inline/css-inline-win32-x64-msvc@0.14.1': + optional: true + + '@css-inline/css-inline@0.14.1': + optionalDependencies: + '@css-inline/css-inline-android-arm-eabi': 0.14.1 + '@css-inline/css-inline-android-arm64': 0.14.1 + '@css-inline/css-inline-darwin-arm64': 0.14.1 + '@css-inline/css-inline-darwin-x64': 0.14.1 + '@css-inline/css-inline-linux-arm-gnueabihf': 0.14.1 + '@css-inline/css-inline-linux-arm64-gnu': 0.14.1 + '@css-inline/css-inline-linux-arm64-musl': 0.14.1 + '@css-inline/css-inline-linux-x64-gnu': 0.14.1 + '@css-inline/css-inline-linux-x64-musl': 0.14.1 + '@css-inline/css-inline-win32-x64-msvc': 0.14.1 + + '@darabonba/typescript@1.0.2': + dependencies: + httpx: 2.3.3 + lodash: 4.17.21 + moment: 2.30.1 + moment-timezone: 0.5.46 + xml2js: 0.6.2 + transitivePeerDependencies: + - supports-color + + '@eslint-community/eslint-utils@4.4.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.12.6 + debug: 4.4.0 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@fastify/busboy@2.1.1': {} + + '@fidm/asn1@1.0.4': {} + + '@fidm/x509@1.2.1': + dependencies: + '@fidm/asn1': 1.0.4 + tweetnacl: 1.0.3 + + '@hapi/address@4.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@hapi/formula@2.0.0': {} + + '@hapi/hoek@9.3.0': {} + + '@hapi/joi@17.1.1': + dependencies: + '@hapi/address': 4.1.0 + '@hapi/formula': 2.0.0 + '@hapi/hoek': 9.3.0 + '@hapi/pinpoint': 2.0.1 + '@hapi/topo': 5.1.0 + + '@hapi/pinpoint@2.0.1': {} + + '@hapi/topo@5.1.0': + dependencies: + '@hapi/hoek': 9.3.0 + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.0 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@ioredis/commands@1.2.0': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + jest-mock: 29.7.0 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.17.12 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + '@types/node': 20.17.12 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.7 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.26.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.25 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.17.12 + '@types/yargs': 17.0.33 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/source-map@0.3.6': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@ljharb/through@2.3.13': + dependencies: + call-bind: 1.0.8 + + '@lukeed/csprng@1.1.0': {} + + '@microsoft/tsdoc@0.15.1': {} + + '@mongodb-js/saslprep@1.1.9': + dependencies: + sparse-bitfield: 3.0.3 + + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + + '@nestjs-modules/mailer@2.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(nodemailer@7.0.3)': + dependencies: + '@css-inline/css-inline': 0.14.1 + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + glob: 10.3.12 + nodemailer: 7.0.3 + optionalDependencies: + '@types/ejs': 3.1.5 + '@types/mjml': 4.7.4 + '@types/pug': 2.0.10 + ejs: 3.1.10 + handlebars: 4.7.8 + liquidjs: 10.21.1 + mjml: 4.15.3 + preview-email: 3.1.0 + pug: 3.0.3 + transitivePeerDependencies: + - encoding + + '@nestjs/axios@3.1.3(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(axios@1.7.9)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + axios: 1.7.9 + rxjs: 7.8.1 + + '@nestjs/bull-shared@11.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + tslib: 2.8.1 + + '@nestjs/bullmq@11.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(bullmq@5.52.2)': + dependencies: + '@nestjs/bull-shared': 11.0.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + bullmq: 5.52.2 + tslib: 2.8.1 + + '@nestjs/cli@10.4.9': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics-cli': 17.3.11(chokidar@3.6.0) + '@nestjs/schematics': 10.2.3(chokidar@3.6.0)(typescript@5.7.2) + chalk: 4.1.2 + chokidar: 3.6.0 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1) + glob: 10.4.5 + inquirer: 8.2.6 + node-emoji: 1.11.0 + ora: 5.4.1 + tree-kill: 1.2.2 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.7.2 + webpack: 5.97.1 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - esbuild + - uglify-js + - webpack-cli + + '@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + dependencies: + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/config@3.3.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + dotenv: 16.4.5 + dotenv-expand: 10.0.0 + lodash: 4.17.21 + rxjs: 7.8.1 + + '@nestjs/core@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nuxtjs/opencollective': 0.3.2 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.1 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + transitivePeerDependencies: + - encoding + + '@nestjs/event-emitter@2.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + eventemitter2: 6.4.9 + + '@nestjs/jwt@10.2.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@types/jsonwebtoken': 9.0.5 + jsonwebtoken: 9.0.2 + + '@nestjs/mapped-types@2.0.6(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/mongoose@10.1.0(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(mongoose@8.9.4)(rxjs@7.8.1)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + mongoose: 8.9.4 + rxjs: 7.8.1 + + '@nestjs/platform-express@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + body-parser: 1.20.3 + cors: 2.8.5 + express: 4.21.2 + multer: 1.4.4-lts.1 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schedule@4.1.2(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + cron: 3.2.1 + uuid: 11.0.3 + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.2 + transitivePeerDependencies: + - chokidar + + '@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.3)': + dependencies: + '@angular-devkit/core': 17.3.11(chokidar@3.6.0) + '@angular-devkit/schematics': 17.3.11(chokidar@3.6.0) + comment-json: 4.2.5 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.7.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/swagger@8.1.1(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)': + dependencies: + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/mapped-types': 2.0.6(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.18.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.1 + + '@nestjs/testing@10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15)(@nestjs/platform-express@10.4.15)': + dependencies: + '@nestjs/common': 10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/core': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/platform-express@10.4.15)(reflect-metadata@0.2.2)(rxjs@7.8.1) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 10.4.15(@nestjs/common@10.4.15(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@10.4.15) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.18.0 + + '@nuxtjs/opencollective@0.3.2': + dependencies: + chalk: 4.1.2 + consola: 2.15.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + '@one-ini/wasm@0.1.1': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.1.1': {} + + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@scarf/scarf@1.4.0': {} + + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + optional: true + + '@sinclair/typebox@0.27.8': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@testcontainers/mongodb@10.16.0': + dependencies: + testcontainers: 10.16.0 + transitivePeerDependencies: + - bare-buffer + - supports-color + + '@testcontainers/redis@10.16.0': + dependencies: + testcontainers: 10.16.0 + transitivePeerDependencies: + - bare-buffer + - supports-color + + '@tsconfig/node10@1.0.11': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.26.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.26.5 + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.17.12 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.17.12 + + '@types/cookiejar@2.1.5': {} + + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 20.17.12 + '@types/ssh2': 1.15.4 + + '@types/dockerode@3.3.34': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 20.17.12 + '@types/ssh2': 1.15.4 + + '@types/ejs@3.1.5': + optional: true + + '@types/eslint-scope@3.7.7': + dependencies: + '@types/eslint': 9.6.1 + '@types/estree': 1.0.6 + + '@types/eslint@9.6.1': + dependencies: + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + + '@types/estree@1.0.6': {} + + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 20.17.12 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@4.17.21': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.7 + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 20.17.12 + + '@types/http-errors@2.0.4': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.14': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/jsonwebtoken@9.0.5': + dependencies: + '@types/node': 20.17.12 + + '@types/luxon@3.4.2': {} + + '@types/methods@1.1.4': {} + + '@types/mime@1.3.5': {} + + '@types/mime@2.0.3': {} + + '@types/mjml-core@4.15.1': + optional: true + + '@types/mjml@4.7.4': + dependencies: + '@types/mjml-core': 4.15.1 + optional: true + + '@types/multer@1.4.12': + dependencies: + '@types/express': 4.17.21 + + '@types/node-fetch@2.6.12': + dependencies: + '@types/node': 20.17.12 + form-data: 4.0.3 + + '@types/node@12.20.55': {} + + '@types/node@18.19.71': + dependencies: + undici-types: 5.26.5 + + '@types/node@20.17.12': + dependencies: + undici-types: 6.19.8 + + '@types/node@22.10.7': + dependencies: + undici-types: 6.20.0 + + '@types/nodemailer@6.4.17': + dependencies: + '@types/node': 20.17.12 + + '@types/pug@2.0.10': + optional: true + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.17.12 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.17.12 + '@types/send': 0.17.4 + + '@types/ssh2-streams@0.1.12': + dependencies: + '@types/node': 20.17.12 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 20.17.12 + '@types/ssh2-streams': 0.1.12 + + '@types/ssh2@1.15.4': + dependencies: + '@types/node': 18.19.71 + + '@types/stack-utils@2.0.3': {} + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 20.17.12 + form-data: 4.0.3 + + '@types/supertest@6.0.2': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/validator@13.12.2': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@11.0.5': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@types/xml-js@1.0.0': + dependencies: + xml-js: 1.6.11 + + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 20.17.12 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.33': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.19.1(@typescript-eslint/parser@8.19.1(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)(typescript@5.7.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.19.1(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/type-utils': 8.19.1(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.1(eslint@8.57.1)(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.19.1 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.19.1(eslint@8.57.1)(typescript@5.7.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.19.1 + debug: 4.4.0 + eslint: 8.57.1 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.19.1': + dependencies: + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/visitor-keys': 8.19.1 + + '@typescript-eslint/type-utils@8.19.1(eslint@8.57.1)(typescript@5.7.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.7.3) + '@typescript-eslint/utils': 8.19.1(eslint@8.57.1)(typescript@5.7.3) + debug: 4.4.0 + eslint: 8.57.1 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.19.1': {} + + '@typescript-eslint/typescript-estree@8.19.1(typescript@5.7.3)': + dependencies: + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/visitor-keys': 8.19.1 + debug: 4.4.0 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 2.0.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.19.1(eslint@8.57.1)(typescript@5.7.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.19.1 + '@typescript-eslint/types': 8.19.1 + '@typescript-eslint/typescript-estree': 8.19.1(typescript@5.7.3) + eslint: 8.57.1 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.19.1': + dependencies: + '@typescript-eslint/types': 8.19.1 + eslint-visitor-keys: 4.2.0 + + '@ungap/structured-clone@1.2.1': {} + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + abbrev@2.0.0: + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.14.0): + dependencies: + acorn: 8.14.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.14.0 + + acorn@7.4.1: + optional: true + + acorn@8.14.0: {} + + address@1.2.2: {} + + afinn-165-financialmarketnews@3.0.0: {} + + afinn-165@1.0.4: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.3: {} + + agentkeepalive@3.5.3: + dependencies: + humanize-ms: 1.2.1 + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + ajv-formats@2.1.1(ajv@8.12.0): + optionalDependencies: + ajv: 8.12.0 + + ajv-formats@2.1.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv-keywords@3.5.2(ajv@6.12.6): + dependencies: + ajv: 6.12.6 + + ajv-keywords@5.1.0(ajv@8.17.1): + dependencies: + ajv: 8.17.1 + fast-deep-equal: 3.1.3 + + ajv@6.12.6: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.12.0: + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.5 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + alce@1.2.0: + dependencies: + esprima: 1.2.5 + estraverse: 1.9.3 + optional: true + + ali-oss@6.22.0: + dependencies: + address: 1.2.2 + agentkeepalive: 3.5.3 + bowser: 1.9.4 + copy-to: 2.0.1 + dateformat: 2.2.0 + debug: 4.4.0 + destroy: 1.2.0 + end-or-error: 1.0.1 + get-ready: 1.0.0 + humanize-ms: 1.2.1 + is-type-of: 1.4.0 + js-base64: 2.6.4 + jstoxml: 2.2.9 + lodash: 4.17.21 + merge-descriptors: 1.0.3 + mime: 2.6.0 + platform: 1.3.6 + pump: 3.0.2 + qs: 6.14.0 + sdk-base: 2.0.1 + stream-http: 2.8.2 + stream-wormhole: 1.1.0 + urllib: 2.44.0 + utility: 1.18.0 + xml2js: 0.6.2 + transitivePeerDependencies: + - proxy-agent + - supports-color + + ansi-colors@4.1.3: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + apparatus@0.0.10: + dependencies: + sylvester: 0.0.12 + + append-field@1.0.0: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.4.5 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.21 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + + arg@4.1.3: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + array-flatten@1.1.1: {} + + array-timsort@1.0.3: {} + + asap@2.0.6: {} + + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + + assert-never@1.4.0: + optional: true + + async-lock@1.4.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.3 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + b4a@1.6.7: {} + + babel-jest@29.7.0(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.26.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.26.5 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 + + babel-preset-current-node-syntax@1.1.0(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.26.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.26.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.26.0) + '@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.26.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.26.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.26.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.26.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.26.0) + + babel-preset-jest@29.6.3(@babel/core@7.26.0): + dependencies: + '@babel/core': 7.26.0 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.0) + + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.26.5 + optional: true + + balanced-match@1.0.2: {} + + bare-events@2.5.4: + optional: true + + bare-fs@4.0.1: + dependencies: + bare-events: 2.5.4 + bare-path: 3.0.0 + bare-stream: 2.6.4(bare-events@2.5.4) + transitivePeerDependencies: + - bare-buffer + optional: true + + bare-os@3.4.0: + optional: true + + bare-path@3.0.0: + dependencies: + bare-os: 3.4.0 + optional: true + + bare-stream@2.6.4(bare-events@2.5.4): + dependencies: + streamx: 2.21.1 + optionalDependencies: + bare-events: 2.5.4 + optional: true + + base64-js@1.5.1: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bignumber.js@4.1.0: {} + + bignumber.js@9.3.0: {} + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + body-parser@2.2.0: + 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.1 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + bowser@1.9.4: {} + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.4: + dependencies: + caniuse-lite: 1.0.30001692 + electron-to-chromium: 1.5.80 + node-releases: 2.0.19 + update-browserslist-db: 1.1.2(browserslist@4.24.4) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + bson@6.10.1: {} + + buffer-crc32@1.0.0: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.6: + optional: true + + builtin-status-codes@3.0.0: {} + + bullmq@5.52.2: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.4.2 + msgpackr: 1.11.3 + node-abort-controller: 3.1.1 + semver: 7.6.3 + tslib: 2.8.1 + uuid: 9.0.1 + transitivePeerDependencies: + - supports-color + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + byline@5.0.0: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.1: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.1 + es-define-property: 1.0.1 + get-intrinsic: 1.2.7 + set-function-length: 1.2.2 + + call-bound@1.0.3: + dependencies: + call-bind-apply-helpers: 1.0.1 + get-intrinsic: 1.2.7 + + callsites@3.1.0: {} + + camel-case@3.0.0: + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + optional: true + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001692: {} + + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + optional: true + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.4.1: {} + + char-regex@1.0.2: {} + + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + optional: true + + chardet@0.7.0: {} + + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.1.0 + css-what: 6.1.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.0 + htmlparser2: 9.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.21.3 + whatwg-mimetype: 4.0.0 + + cheerio@1.0.0-rc.12: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + htmlparser2: 8.0.2 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + optional: true + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + chrome-trace-event@1.0.4: {} + + ci-info@3.9.0: {} + + cjs-module-lexer@1.4.1: {} + + class-transformer@0.5.1: {} + + class-validator@0.14.1: + dependencies: + '@types/validator': 13.12.2 + libphonenumber-js: 1.11.17 + validator: 13.12.0 + + clean-css@4.2.4: + dependencies: + source-map: 0.6.1 + optional: true + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-width@3.0.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone@1.0.4: {} + + cluster-key-slot@1.1.2: {} + + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: + optional: true + + commander@2.20.3: {} + + commander@4.1.1: {} + + commander@6.2.1: + optional: true + + comment-json@4.2.5: + dependencies: + array-timsort: 1.0.3 + core-util-is: 1.0.3 + esprima: 4.0.1 + has-own-prop: 2.0.0 + repeat-string: 1.6.1 + + component-emitter@1.3.1: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-map@0.0.1: {} + + concat-stream@1.6.2: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + optional: true + + consola@2.15.3: {} + + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + optional: true + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.6: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.1: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + copy-to@2.0.1: {} + + core-util-is@1.0.3: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + corser@2.0.1: {} + + cosmiconfig@8.3.6(typescript@5.7.2): + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + optionalDependencies: + typescript: 5.7.2 + + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.6 + nan: 2.22.0 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + create-jest@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + cron-parser@4.9.0: + dependencies: + luxon: 3.5.0 + + cron@3.2.1: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-select@5.1.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.1.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.1.0: {} + + dateformat@2.2.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + dedent@1.5.3: {} + + deep-extend@0.6.0: + optional: true + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-user-agent@1.0.0: + dependencies: + os-name: 1.0.3 + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + delayed-stream@1.0.0: {} + + denque@2.1.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-indent@6.1.0: + optional: true + + detect-libc@2.0.4: + optional: true + + detect-newline@3.1.0: {} + + detect-node@2.1.0: + optional: true + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff-sequences@29.6.3: {} + + diff@4.0.2: {} + + digest-header@1.1.0: {} + + display-notification@2.0.0: + dependencies: + escape-string-applescript: 1.0.0 + run-applescript: 3.2.0 + optional: true + + docker-compose@0.24.8: + dependencies: + yaml: 2.7.0 + + docker-modem@3.0.8: + dependencies: + debug: 4.4.0 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.16.0 + transitivePeerDependencies: + - supports-color + + dockerode@3.3.5: + dependencies: + '@balena/dockerignore': 1.0.2 + docker-modem: 3.0.8 + tar-fs: 2.0.1 + transitivePeerDependencies: + - supports-color + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + doctypes@1.1.0: + optional: true + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + optional: true + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@3.3.0: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + optional: true + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + optional: true + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dotenv-expand@10.0.0: {} + + dotenv@16.4.5: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + editorconfig@1.0.4: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.1 + semver: 7.6.3 + optional: true + + ee-first@1.1.1: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.2 + + electron-to-chromium@1.5.80: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@1.0.2: {} + + encodeurl@2.0.0: {} + + encoding-japanese@2.2.0: + optional: true + + encoding-sniffer@0.2.0: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + + end-of-stream@1.4.4: + dependencies: + once: 1.4.0 + + end-or-error@1.0.1: {} + + enhanced-resolve@5.18.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + entities@2.2.0: + optional: true + + entities@4.5.0: {} + + entities@6.0.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.6.0: {} + + es-object-atoms@1.0.0: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + escape-goat@3.0.0: + optional: true + + escape-html@1.0.3: {} + + escape-string-applescript@1.0.0: + optional: true + + escape-string-regexp@1.0.5: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@9.1.0(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@8.57.1))(eslint@8.57.1)(prettier@3.4.2): + dependencies: + eslint: 8.57.1 + prettier: 3.4.2 + prettier-linter-helpers: 1.0.0 + synckit: 0.9.2 + optionalDependencies: + '@types/eslint': 9.6.1 + eslint-config-prettier: 9.1.0(eslint@8.57.1) + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.1 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.0 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.14.0 + acorn-jsx: 5.3.2(acorn@8.14.0) + eslint-visitor-keys: 3.4.3 + + esprima@1.2.5: + optional: true + + esprima@4.0.1: {} + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@1.9.3: + optional: true + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + eventemitter2@6.4.9: {} + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + execa@0.10.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 3.0.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + optional: true + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit@0.1.2: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + express-session@1.18.1: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.0.2 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + express@5.1.0: + 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.2 + 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.1 + 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.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend-object@1.0.0: + optional: true + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + + external-editor@3.1.0: + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-safe-stringify@2.1.1: {} + + fast-uri@3.0.5: {} + + fast-xml-parser@5.2.1: + dependencies: + strnum: 2.0.5 + + fastq@1.18.0: + dependencies: + reusify: 1.0.4 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + figures@3.2.0: + dependencies: + escape-string-regexp: 1.0.5 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + filelist@1.0.4: + dependencies: + minimatch: 5.1.6 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.0: + 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 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fixpack@4.0.0: + dependencies: + alce: 1.2.0 + chalk: 3.0.0 + detect-indent: 6.1.0 + detect-newline: 3.1.0 + extend-object: 1.0.0 + rc: 1.2.8 + optional: true + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.2 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.2: {} + + follow-redirects@1.15.9: {} + + foreachasync@3.0.0: {} + + foreground-child@3.3.0: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1): + dependencies: + '@babel/code-frame': 7.26.2 + chalk: 4.1.2 + chokidar: 3.6.0 + cosmiconfig: 8.3.6(typescript@5.7.2) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.2 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.6.3 + tapable: 2.2.1 + typescript: 5.7.2 + webpack: 5.97.1 + + form-data-encoder@1.7.2: {} + + form-data@3.0.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + + form-data@4.0.3: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + + formidable@2.1.2: + dependencies: + dezalgo: 1.0.4 + hexoid: 1.0.0 + once: 1.4.0 + qs: 6.14.0 + + formidable@3.5.2: + dependencies: + dezalgo: 1.0.4 + hexoid: 2.0.0 + once: 1.4.0 + + formstream@1.5.1: + dependencies: + destroy: 1.2.0 + mime: 2.6.0 + node-hex: 1.0.1 + pause-stream: 0.0.11 + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fs-monkey@1.0.6: {} + + fs.realpath@1.0.0: {} + + fs@0.0.1-security: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gaxios@6.7.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + gcp-metadata@6.1.1: + dependencies: + gaxios: 6.7.1 + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + generic-pool@3.9.0: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.2.7: + dependencies: + call-bind-apply-helpers: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + 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 + + get-package-type@0.1.0: {} + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.0.0 + + get-ready@1.0.0: {} + + get-stream@3.0.0: + optional: true + + get-stream@6.0.1: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob-to-regexp@0.4.1: {} + + glob@10.3.12: + dependencies: + foreground-child: 3.3.0 + jackspeak: 2.3.6 + minimatch: 9.0.5 + minipass: 7.1.2 + path-scurry: 1.11.1 + + glob@10.4.5: + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@11.12.0: {} + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + google-auth-library@9.15.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 6.7.1 + gcp-metadata: 6.1.1 + gtoken: 7.1.0 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + google-logging-utils@0.0.2: {} + + googleapis-common@7.2.0: + dependencies: + extend: 3.0.2 + gaxios: 6.7.1 + google-auth-library: 9.15.1 + qs: 6.14.0 + url-template: 2.0.8 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + googleapis@148.0.0: + dependencies: + google-auth-library: 9.15.1 + googleapis-common: 7.2.0 + transitivePeerDependencies: + - encoding + - supports-color + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gtoken@7.1.0: + dependencies: + gaxios: 6.7.1 + jws: 4.0.0 + transitivePeerDependencies: + - encoding + - supports-color + + handlebars@4.7.7: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + optional: true + + has-flag@4.0.0: {} + + has-own-prop@2.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hbs@4.2.0: + dependencies: + handlebars: 4.7.7 + walk: 2.3.15 + + he@1.2.0: {} + + hexoid@1.0.0: {} + + hexoid@2.0.0: {} + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + html-escaper@2.0.2: {} + + html-minifier@4.0.0: + dependencies: + camel-case: 3.0.0 + clean-css: 4.2.4 + commander: 2.20.3 + he: 1.2.0 + param-case: 2.1.1 + relateurl: 0.2.7 + uglify-js: 3.19.3 + optional: true + + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + optional: true + + htmlparser2@5.0.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 3.3.0 + domutils: 2.8.0 + entities: 2.2.0 + optional: true + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + http-proxy@1.18.1: + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.9 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + http-server@14.1.1: + dependencies: + basic-auth: 2.0.1 + chalk: 4.1.2 + corser: 2.0.1 + he: 1.2.0 + html-encoding-sniffer: 3.0.0 + http-proxy: 1.18.1 + mime: 1.6.0 + minimist: 1.2.8 + opener: 1.5.2 + portfinder: 1.0.37 + secure-compare: 3.0.1 + union: 0.5.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + httpx@2.3.3: + dependencies: + '@types/node': 20.17.12 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-fresh@3.3.0: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.3: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + inquirer@8.2.6: + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + ora: 5.4.1 + run-async: 2.4.1 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + wrap-ansi: 6.2.0 + + inquirer@9.2.15: + dependencies: + '@ljharb/through': 2.3.13 + ansi-escapes: 4.3.2 + chalk: 5.4.1 + cli-cursor: 3.1.0 + cli-width: 4.1.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 1.0.0 + ora: 5.4.1 + run-async: 3.0.0 + rxjs: 7.8.1 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + int64-buffer@0.99.1007: {} + + ioredis@5.4.2: + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.4.0 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-class-hotfix@0.0.6: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-docker@2.2.1: + optional: true + + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + optional: true + + is-extendable@0.1.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-promise@2.2.2: + optional: true + + is-promise@4.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.3 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + optional: true + + is-stream@1.1.0: + optional: true + + is-stream@2.0.1: {} + + is-type-of@1.4.0: + dependencies: + core-util-is: 1.0.3 + is-class-hotfix: 0.0.6 + isstream: 0.1.2 + + is-unicode-supported@0.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + optional: true + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + isstream@0.1.2: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.26.0 + '@babel/parser': 7.26.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.26.0 + '@babel/parser': 7.26.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.0 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.7: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + iterare@1.2.1: {} + + jackspeak@2.3.6: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.2: + dependencies: + async: 3.2.6 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.3 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.1.0 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)): + dependencies: + '@babel/core': 7.26.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 20.17.12 + ts-node: 10.9.2(@types/node@20.17.12)(typescript@5.7.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 20.17.12 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.26.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + jest-util: 29.7.0 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.10 + resolve.exports: 2.0.3 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + chalk: 4.1.2 + cjs-module-lexer: 1.4.1 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.26.0 + '@babel/generator': 7.26.5 + '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.0) + '@babel/plugin-syntax-typescript': 7.25.9(@babel/core@7.26.0) + '@babel/types': 7.26.5 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.1.0(@babel/core@7.26.0) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.17.12 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@27.5.1: + dependencies: + '@types/node': 20.17.12 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@29.7.0: + dependencies: + '@types/node': 20.17.12 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + js-base64@2.6.4: {} + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.4 + glob: 10.4.5 + js-cookie: 3.0.5 + nopt: 7.2.1 + optional: true + + js-cookie@3.0.5: + optional: true + + js-stringify@1.0.2: + optional: true + + js-tokens@4.0.0: {} + + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-bigint@0.2.3: + dependencies: + bignumber.js: 4.1.0 + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.0 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonc-parser@3.3.1: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsonwebtoken@9.0.2: + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.3 + + jstoxml@2.2.9: {} + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + optional: true + + juice@10.0.1: + dependencies: + cheerio: 1.0.0-rc.12 + commander: 6.2.1 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 6.0.1 + transitivePeerDependencies: + - encoding + optional: true + + jwa@1.4.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@3.2.2: + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + + jws@4.0.0: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kareem@2.6.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kitx@1.3.0: {} + + kitx@2.2.0: + dependencies: + '@types/node': 22.10.7 + + kleur@3.0.3: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + leac@0.6.0: + optional: true + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + libbase64@1.3.0: + optional: true + + libmime@5.3.6: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.1 + optional: true + + libphonenumber-js@1.11.17: {} + + libqp@2.1.1: + optional: true + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + optional: true + + liquidjs@10.21.1: + dependencies: + commander: 10.0.1 + optional: true + + loader-runner@4.3.0: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.defaults@4.2.0: {} + + lodash.includes@4.3.0: {} + + lodash.isarguments@3.1.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lower-case@1.1.4: + optional: true + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + luxon@3.5.0: {} + + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + mailparser@3.7.3: + dependencies: + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.6.3 + libmime: 5.3.6 + linkify-it: 5.0.0 + mailsplit: 5.4.3 + nodemailer: 7.0.3 + punycode.js: 2.3.1 + tlds: 1.259.0 + optional: true + + mailsplit@5.4.3: + dependencies: + libbase64: 1.3.0 + libmime: 5.3.6 + libqp: 2.1.1 + optional: true + + make-dir@4.0.0: + dependencies: + semver: 7.6.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + memfs@3.5.3: + dependencies: + fs-monkey: 1.0.6 + + memjs@1.3.2: {} + + memory-pager@1.5.0: {} + + mensch@0.3.4: + optional: true + + merge-descriptors@1.0.3: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + mime@1.6.0: {} + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.1 + + minimatch@9.0.1: + dependencies: + brace-expansion: 2.0.1 + optional: true + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mjml-accordion@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-body@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-button@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-carousel@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-cli@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + chokidar: 3.6.0 + glob: 10.4.5 + html-minifier: 4.0.0 + js-beautify: 1.15.4 + lodash: 4.17.21 + minimatch: 9.0.5 + mjml-core: 4.15.3 + mjml-migrate: 4.15.3 + mjml-parser-xml: 4.15.3 + mjml-validator: 4.15.3 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + optional: true + + mjml-column@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-core@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + cheerio: 1.0.0-rc.12 + detect-node: 2.1.0 + html-minifier: 4.0.0 + js-beautify: 1.15.4 + juice: 10.0.1 + lodash: 4.17.21 + mjml-migrate: 4.15.3 + mjml-parser-xml: 4.15.3 + mjml-validator: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-divider@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-group@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-attributes@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-breakpoint@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-font@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-html-attributes@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-preview@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-style@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head-title@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-head@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-hero@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-image@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-migrate@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + js-beautify: 1.15.4 + lodash: 4.17.21 + mjml-core: 4.15.3 + mjml-parser-xml: 4.15.3 + yargs: 17.7.2 + transitivePeerDependencies: + - encoding + optional: true + + mjml-navbar@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-parser-xml@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + detect-node: 2.1.0 + htmlparser2: 9.1.0 + lodash: 4.17.21 + optional: true + + mjml-preset-core@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + mjml-accordion: 4.15.3 + mjml-body: 4.15.3 + mjml-button: 4.15.3 + mjml-carousel: 4.15.3 + mjml-column: 4.15.3 + mjml-divider: 4.15.3 + mjml-group: 4.15.3 + mjml-head: 4.15.3 + mjml-head-attributes: 4.15.3 + mjml-head-breakpoint: 4.15.3 + mjml-head-font: 4.15.3 + mjml-head-html-attributes: 4.15.3 + mjml-head-preview: 4.15.3 + mjml-head-style: 4.15.3 + mjml-head-title: 4.15.3 + mjml-hero: 4.15.3 + mjml-image: 4.15.3 + mjml-navbar: 4.15.3 + mjml-raw: 4.15.3 + mjml-section: 4.15.3 + mjml-social: 4.15.3 + mjml-spacer: 4.15.3 + mjml-table: 4.15.3 + mjml-text: 4.15.3 + mjml-wrapper: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-raw@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-section@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-social@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-spacer@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-table@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-text@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml-validator@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + optional: true + + mjml-wrapper@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + lodash: 4.17.21 + mjml-core: 4.15.3 + mjml-section: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mjml@4.15.3: + dependencies: + '@babel/runtime': 7.27.1 + mjml-cli: 4.15.3 + mjml-core: 4.15.3 + mjml-migrate: 4.15.3 + mjml-preset-core: 4.15.3 + mjml-validator: 4.15.3 + transitivePeerDependencies: + - encoding + optional: true + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + mkdirp@1.0.4: {} + + moment-timezone@0.5.46: + dependencies: + moment: 2.30.1 + + moment@2.30.1: {} + + mongodb-connection-string-url@3.0.1: + dependencies: + '@types/whatwg-url': 11.0.5 + whatwg-url: 13.0.0 + + mongodb@6.12.0: + dependencies: + '@mongodb-js/saslprep': 1.1.9 + bson: 6.10.1 + mongodb-connection-string-url: 3.0.1 + + mongoose@8.9.4: + dependencies: + bson: 6.10.1 + kareem: 2.6.3 + mongodb: 6.12.0 + mpath: 0.9.0 + mquery: 5.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + - supports-color + + mpath@0.9.0: {} + + mquery@5.0.0: + dependencies: + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.3: + optionalDependencies: + msgpackr-extract: 3.0.3 + + multer@1.4.4-lts.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + multer@2.0.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 1.6.2 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + + mute-stream@0.0.8: {} + + mute-stream@1.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nan@2.22.0: + optional: true + + natural-compare@1.4.0: {} + + natural@8.0.1: + dependencies: + afinn-165: 1.0.4 + afinn-165-financialmarketnews: 3.0.0 + apparatus: 0.0.10 + dotenv: 16.4.5 + http-server: 14.1.1 + memjs: 1.3.2 + mongoose: 8.9.4 + pg: 8.16.0 + redis: 4.7.1 + safe-stable-stringify: 2.5.0 + stopwords-iso: 1.1.0 + sylvester: 0.0.12 + underscore: 1.13.7 + uuid: 9.0.1 + wordnet-db: 3.1.14 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - debug + - gcp-metadata + - kerberos + - mongodb-client-encryption + - pg-native + - snappy + - socks + - supports-color + + negotiator@0.6.3: {} + + negotiator@1.0.0: {} + + neo-async@2.6.2: {} + + nest-wechatpay-node-v3@1.0.2: {} + + nice-try@1.0.5: + optional: true + + no-case@2.3.2: + dependencies: + lower-case: 1.1.4 + optional: true + + node-abort-controller@3.1.1: {} + + node-domexception@1.0.0: {} + + node-emoji@1.11.0: + dependencies: + lodash: 4.17.21 + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.0.4 + optional: true + + node-hex@1.0.1: {} + + node-int64@0.4.0: {} + + node-releases@2.0.19: {} + + nodemailer@6.10.1: + optional: true + + nodemailer@7.0.3: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + optional: true + + normalize-path@3.0.0: {} + + npm-run-path@2.0.2: + dependencies: + path-key: 2.0.1 + optional: true + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.3: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + optional: true + + openai@4.96.0: + dependencies: + '@types/node': 18.19.71 + '@types/node-fetch': 2.6.12 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + + opener@1.5.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + os-name@1.0.3: + dependencies: + osx-release: 1.1.0 + win-release: 1.1.1 + + os-tmpdir@1.0.2: {} + + osx-release@1.1.0: + dependencies: + minimist: 1.2.8 + + p-event@4.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + p-finally@1.0.0: + optional: true + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + optional: true + + p-try@2.2.0: {} + + p-wait-for@3.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + package-json-from-dist@1.0.1: {} + + param-case@2.1.1: + dependencies: + no-case: 2.3.2 + optional: true + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.26.2 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.0 + + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + optional: true + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@2.0.1: + optional: true + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@0.1.12: {} + + path-to-regexp@3.3.0: {} + + path-to-regexp@8.2.0: {} + + path-type@4.0.0: {} + + path@0.12.7: + dependencies: + process: 0.11.10 + util: 0.10.4 + + pause-stream@0.0.11: + dependencies: + through: 2.3.8 + + peberminta@0.9.0: + optional: true + + pg-cloudflare@1.2.5: + optional: true + + pg-connection-string@2.9.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.10.0(pg@8.16.0): + dependencies: + pg: 8.16.0 + + pg-protocol@1.10.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.0 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.16.0: + dependencies: + pg-connection-string: 2.9.0 + pg-pool: 3.10.0(pg@8.16.0) + pg-protocol: 1.10.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.2.5 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.1: {} + + pirates@4.0.6: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + platform@1.3.6: {} + + pluralize@8.0.0: {} + + portfinder@1.0.37: + dependencies: + async: 3.2.6 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.0: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.0: + dependencies: + fast-diff: 1.3.0 + + prettier@3.4.2: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + preview-email@3.1.0: + dependencies: + ci-info: 3.9.0 + display-notification: 2.0.0 + fixpack: 4.0.0 + get-port: 5.1.1 + mailparser: 3.7.3 + nodemailer: 6.10.1 + open: 7.4.2 + p-event: 4.2.0 + p-wait-for: 3.2.0 + pug: 3.0.3 + uuid: 9.0.1 + optional: true + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + optional: true + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + properties-reader@2.3.0: + dependencies: + mkdirp: 1.0.4 + + proto-list@1.2.4: + optional: true + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@1.1.0: {} + + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + optional: true + + pug-code-gen@3.0.3: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + optional: true + + pug-error@2.1.0: + optional: true + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.10 + optional: true + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + optional: true + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + optional: true + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + optional: true + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + optional: true + + pug-runtime@3.0.1: + optional: true + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + optional: true + + pug-walk@2.0.0: + optional: true + + pug@3.0.3: + dependencies: + pug-code-gen: 3.0.3 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + optional: true + + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + punycode.js@2.3.1: + optional: true + + punycode@2.3.1: {} + + pure-rand@6.1.0: {} + + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + queue-tick@1.0.1: {} + + random-bytes@1.0.0: {} + + randombytes@2.1.0: + dependencies: + safe-buffer: 5.2.1 + + range-parser@1.2.1: {} + + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + + react-is@18.3.1: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + + reflect-metadata@0.2.2: {} + + relateurl@0.2.7: + optional: true + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + requires-port@1.0.0: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + reusify@1.0.4: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + router@2.2.0: + dependencies: + debug: 4.4.0 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + + run-applescript@3.2.0: + dependencies: + execa: 0.10.0 + optional: true + + run-async@2.4.1: {} + + run-async@3.0.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rxjs@7.8.1: + dependencies: + tslib: 2.8.1 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + sax@1.4.1: {} + + schema-utils@3.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + + schema-utils@4.3.0: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + ajv-keywords: 5.1.0(ajv@8.17.1) + + sdk-base@2.0.1: + dependencies: + get-ready: 1.0.0 + + secure-compare@3.0.1: {} + + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + optional: true + + semver@5.7.2: {} + + semver@6.3.1: {} + + semver@7.6.3: {} + + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + send@1.2.0: + dependencies: + debug: 4.4.0 + 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 + transitivePeerDependencies: + - supports-color + + serialize-javascript@6.0.2: + dependencies: + randombytes: 2.1.0 + + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.7 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + setprototypeof@1.2.0: {} + + shebang-command@1.2.0: + dependencies: + shebang-regex: 1.0.0 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@1.0.0: + optional: true + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.3 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.3 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.3 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + 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 + + sift@17.1.3: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + slash@3.0.0: {} + + slick@1.12.2: + optional: true + + sm3@1.0.3: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.4: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + split-ca@1.0.1: {} + + split2@4.2.0: {} + + sprintf-js@1.0.3: {} + + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.16.0 + + ssh2@1.16.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.22.0 + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + standard-as-callback@2.1.0: {} + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + stopwords-iso@1.1.0: {} + + stream-http@2.8.2: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 2.3.8 + to-arraybuffer: 1.0.1 + xtend: 4.0.2 + + stream-wormhole@1.1.0: {} + + streamsearch@1.1.0: {} + + streamx@2.21.1: + dependencies: + fast-fifo: 1.3.2 + queue-tick: 1.0.1 + text-decoder: 1.2.3 + optionalDependencies: + bare-events: 2.5.4 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-eof@1.0.0: + optional: true + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: + optional: true + + strip-json-comments@3.1.1: {} + + strnum@2.0.5: {} + + superagent@8.0.6: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.0 + fast-safe-stringify: 2.1.1 + form-data: 4.0.3 + formidable: 2.1.2 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + + superagent@9.0.2: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.0 + fast-safe-stringify: 2.1.1 + form-data: 4.0.3 + formidable: 3.5.2 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.0 + transitivePeerDependencies: + - supports-color + + supertest@7.0.0: + dependencies: + methods: 1.1.2 + superagent: 9.0.2 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + swagger-ui-dist@5.18.2: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.1.0): + dependencies: + express: 5.1.0 + swagger-ui-dist: 5.18.2 + + sylvester@0.0.12: {} + + symbol-observable@4.0.0: {} + + synckit@0.9.2: + dependencies: + '@pkgr/core': 0.1.1 + tslib: 2.8.1 + + tapable@2.2.1: {} + + tar-fs@2.0.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-fs@3.0.8: + dependencies: + pump: 3.0.2 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 4.0.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-buffer + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.6.7 + fast-fifo: 1.3.2 + streamx: 2.21.1 + + tencentcloud-sdk-nodejs-common@4.1.1: + dependencies: + form-data: 3.0.3 + get-stream: 6.0.1 + https-proxy-agent: 5.0.1 + is-stream: 2.0.1 + json-bigint: 1.0.0 + node-fetch: 2.7.0 + tslib: 1.13.0 + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + tencentcloud-sdk-nodejs-tms@4.0.1052: + dependencies: + tencentcloud-sdk-nodejs-common: 4.1.1 + tslib: 1.13.0 + transitivePeerDependencies: + - encoding + - supports-color + + terser-webpack-plugin@5.3.11(webpack@5.97.1): + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + jest-worker: 27.5.1 + schema-utils: 4.3.0 + serialize-javascript: 6.0.2 + terser: 5.37.0 + webpack: 5.97.1 + + terser@5.37.0: + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.14.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + testcontainers@10.16.0: + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 3.3.34 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.0 + docker-compose: 0.24.8 + dockerode: 3.3.5 + get-port: 5.1.1 + proper-lockfile: 4.1.2 + properties-reader: 2.3.0 + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.0.8 + tmp: 0.2.3 + undici: 5.28.4 + transitivePeerDependencies: + - bare-buffer + - supports-color + + text-decoder@1.2.3: + dependencies: + b4a: 1.6.7 + + text-table@0.2.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + through@2.3.8: {} + + tlds@1.259.0: + optional: true + + tmp@0.0.33: + dependencies: + os-tmpdir: 1.0.2 + + tmp@0.2.3: {} + + tmpl@1.0.5: {} + + to-arraybuffer@1.0.1: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-stream@1.0.0: + optional: true + + tr46@0.0.3: {} + + tr46@4.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-api-utils@2.0.0(typescript@5.7.3): + dependencies: + typescript: 5.7.3 + + ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)))(typescript@5.7.3): + dependencies: + bs-logger: 0.2.6 + ejs: 3.1.10 + fast-json-stable-stringify: 2.1.0 + jest: 29.7.0(@types/node@20.17.12)(ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3)) + jest-util: 29.7.0 + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.6.3 + typescript: 5.7.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.26.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.26.0) + + ts-loader@9.5.2(typescript@5.7.3)(webpack@5.97.1): + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.0 + micromatch: 4.0.8 + semver: 7.6.3 + source-map: 0.7.4 + typescript: 5.7.3 + webpack: 5.97.1 + + ts-node@10.9.2(@types/node@20.17.12)(typescript@5.7.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.11 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 20.17.12 + acorn: 8.14.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.7.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.18.0 + tapable: 2.2.1 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@1.13.0: {} + + tslib@2.8.1: {} + + tweetnacl@0.14.5: {} + + tweetnacl@1.0.3: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.20.2: {} + + type-fest@0.21.3: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typedarray@0.0.6: {} + + typescript@5.7.2: {} + + typescript@5.7.3: {} + + uc.micro@2.1.0: + optional: true + + uglify-js@3.19.3: + optional: true + + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + + uid@2.0.2: + dependencies: + '@lukeed/csprng': 1.1.0 + + underscore@1.13.7: {} + + undici-types@5.26.5: {} + + undici-types@6.19.8: {} + + undici-types@6.20.0: {} + + undici@5.28.4: + dependencies: + '@fastify/busboy': 2.1.1 + + undici@6.21.3: {} + + unescape@1.0.1: + dependencies: + extend-shallow: 2.0.1 + + union@0.5.0: + dependencies: + qs: 6.14.0 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.2(browserslist@4.24.4): + dependencies: + browserslist: 4.24.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + upper-case@1.1.3: + optional: true + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + url-join@4.0.1: {} + + url-template@2.0.8: {} + + urllib@2.44.0: + dependencies: + any-promise: 1.3.0 + content-type: 1.0.5 + default-user-agent: 1.0.0 + digest-header: 1.1.0 + ee-first: 1.1.1 + formstream: 1.5.1 + humanize-ms: 1.2.1 + iconv-lite: 0.6.3 + pump: 3.0.2 + qs: 6.14.0 + statuses: 1.5.0 + utility: 1.18.0 + + util-deprecate@1.0.2: {} + + util@0.10.4: + dependencies: + inherits: 2.0.3 + + utility@1.18.0: + dependencies: + copy-to: 2.0.1 + escape-html: 1.0.3 + mkdirp: 0.5.6 + mz: 2.7.0 + unescape: 1.0.1 + + utils-merge@1.0.1: {} + + uuid@11.0.3: {} + + uuid@11.0.5: {} + + uuid@3.4.0: {} + + uuid@9.0.1: {} + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + valid-data-url@3.0.1: + optional: true + + validator@13.12.0: {} + + vary@1.1.2: {} + + void-elements@3.1.0: + optional: true + + walk@2.3.15: + dependencies: + foreachasync: 3.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + watchpack@2.4.2: + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-resource-inliner@6.0.1: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 5.0.1 + mime: 2.6.0 + node-fetch: 2.7.0 + valid-data-url: 3.0.1 + transitivePeerDependencies: + - encoding + optional: true + + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + webidl-conversions@7.0.0: {} + + webpack-node-externals@3.0.0: {} + + webpack-sources@3.2.3: {} + + webpack@5.97.1: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.6 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.14.0 + browserslist: 4.24.4 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.0 + es-module-lexer: 1.6.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.11(webpack@5.97.1) + watchpack: 2.4.2 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + + wechatpay-node-v3@2.2.1: + dependencies: + '@fidm/x509': 1.2.1 + superagent: 8.0.6 + transitivePeerDependencies: + - supports-color + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@13.0.0: + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + which@1.3.1: + dependencies: + isexe: 2.0.0 + optional: true + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + win-release@1.1.1: + dependencies: + semver: 5.7.2 + + with@7.0.2: + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + optional: true + + word-wrap@1.2.5: {} + + wordnet-db@3.1.14: {} + + wordwrap@1.0.0: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + xml-js@1.6.11: + dependencies: + sax: 1.4.1 + + xml2js@0.4.23: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xml2js@0.6.2: + dependencies: + sax: 1.4.1 + xmlbuilder: 11.0.1 + + xmlbuilder@11.0.1: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@2.7.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/_swagger.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/_swagger.ts new file mode 100644 index 000000000..0bb0ae4f1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/_swagger.ts @@ -0,0 +1,30 @@ +/* + * @Author: nevin + * @Date: 2021-12-21 18:05:12 + * @LastEditors: nevin + * @LastEditTime: 2025-01-15 14:24:51 + * @Description: 文档插件 + */ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +const API_URL = '/docs'; +export function createSwagger(app: INestApplication) { + // const version = require('../package.json').version || ''; // 获取同项目一致版本号 + const version = '1.0.0'; // 获取同项目一致版本号 + + const docConfig = new DocumentBuilder() + .setTitle('爱团团AiToEarnAPI文档') + .setVersion(version) + .addBearerAuth() + .build(); + + const document = SwaggerModule.createDocument(app, docConfig, {}); + SwaggerModule.setup(API_URL, app, document, { + customSiteTitle: `爱团团AiToEarnAPI文档`, + jsonDocumentUrl: `${API_URL}/openapi.json`, // 文档JSON + swaggerOptions: { + persistAuthorization: true, // 保持登录 + }, + }); + return API_URL; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.spec.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.spec.ts new file mode 100644 index 000000000..d22f3890a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.ts new file mode 100644 index 000000000..b00f44972 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.controller.ts @@ -0,0 +1,37 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-03-19 15:34:32 + * @LastEditors: nevin + * @Description: 应用 + */ +import { Controller, Get, Req } from '@nestjs/common'; +import { AppService } from './app.service'; +import { Public } from './auth/auth.guard'; +import { Request as ExRequest } from 'express'; + +@Public() +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + // 获取应用的下载链接 + @Get('down') + getDownUrl() { + return this.appService.getDownUrl(); + } + + @Get() + getHello(): string { + return this.appService.getHello(); + } + + // 获取用户的IP地址 + @Get('ip') + async getIp(@Req() request: ExRequest) { + return { + xForwardedFor: request.headers['x-forwarded-for'] || '', + ip: request.ip || '', + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.module.ts new file mode 100644 index 000000000..679d64747 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.module.ts @@ -0,0 +1,120 @@ +/* + * @Author: nevin + * @Date: 2025-01-15 14:17:16 + * @LastEditTime: 2025-04-27 17:37:13 + * @LastEditors: nevin + * @Description: + */ +import * as Joi from '@hapi/joi'; +import { + MiddlewareConsumer, + Module, + NestModule, + RequestMethod, +} from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import bullMqConfig from '../config/bullMq.config'; +import serverConfig from '../config/server.config'; +import googleConfig from '../config/google.config'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthModule } from './auth/auth.module'; +import { DbMongoModule } from './db/db-mongo.module'; +import { HttpExceptionFilter } from './filters/http-exception.filter'; +import { LoggingInterceptor } from './interceptor/logging.interceptor'; +import { TransformInterceptor } from './interceptor/transform.interceptor'; +import { OssModule } from './lib/oss/oss.module'; +import { RedisModule } from './lib/redis/redis.module'; +import { AlicloudSmsModule } from './lib/sms/alicloud-sms.module'; +import { WxModule } from './lib/wx/wx.module'; +import { ManagerModule } from './manager/manager.module'; +import { TaskModule } from './modules/task/task.module'; +import { UserModule } from './user/user.module'; +import { XMLMiddleware } from './middleware/xml.middleware'; +import { FinanceModule } from './modules/finance/finance.module'; +import { OtherModule } from './modules/other/other.module'; +import { OperateModule } from './modules/operate/operate.module'; +import { ToolsModule } from './modules/tools/tools.module'; +import { AccountModule } from './modules/account/account.module'; +import { PublishModule } from './modules/publish/publish.module'; +import { TracingModule } from './modules/tracing/tracing.module'; +import { RewardModule } from './modules/reward/reward.module'; +import { TmsModule } from './lib/tms/tms.module'; +import { PlatModule } from './modules/plat/plat.module'; +import { BullModule } from '@nestjs/bullmq'; +import { MailModule } from './lib/mail/mail.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + envFilePath: `.env${process.env.NODE_ENV ? '.' + process.env.NODE_ENV : ''}`, + isGlobal: true, + ignoreEnvFile: false, // 取消忽略配置文件,为true则仅读取操作系统环境变量,常用于生产环境 + load: [serverConfig, bullMqConfig, googleConfig], // 加载server.config自定义全局配置项 + validationSchema: Joi.object({ + // 配置文件.env校验 + PORT: Joi.string().default('7000'), + NODE_ENV: Joi.string() + .valid('development', 'production', 'test') + .default('development'), + }), + }), + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + DbMongoModule, + RedisModule, + // 队列 + BullModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + return { + connection: { + host: configService.get('BULLMQ_REDIS_CONFIG.HOST'), + port: configService.get('BULLMQ_REDIS_CONFIG.PORT'), + password: configService.get('BULLMQ_REDIS_CONFIG.PASSWORD'), + db: configService.get('BULLMQ_REDIS_CONFIG.DB'), + removeOnComplete: true, // 完成后删除 + removeOnFail: { count: 1, age: 1000 * 60 * 60 * 3 }, // 失败后保留一条记录一小时 + }, + }; + }, + }), + MailModule, + OssModule, + TmsModule, + AlicloudSmsModule, + AuthModule, + UserModule, + WxModule, + ManagerModule, + TaskModule, + FinanceModule, + OtherModule, + OperateModule, + ToolsModule, + AccountModule, + PublishModule, + TracingModule, + RewardModule, + PlatModule, + ], + controllers: [AppController], + providers: [ + AppService, + { provide: APP_FILTER, useClass: HttpExceptionFilter }, + { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, + { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor }, + ], +}) +export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(XMLMiddleware).forRoutes({ + path: 'wxGzh/*', + method: RequestMethod.POST, + }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.service.ts new file mode 100644 index 000000000..ef2a66000 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/app.service.ts @@ -0,0 +1,46 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-03-19 15:41:06 + * @LastEditors: nevin + * @Description: 应用 + */ +import { Injectable } from '@nestjs/common'; +import * as yaml from 'yaml'; + +async function parseYml(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.status}`); + } + const text = await response.text(); + return yaml.parse(text.replace(/\\/g, '')); + } catch (error) { + console.error(`YAML解析失败: ${url}`, error); + throw error; + } +} + +@Injectable() +export class AppService { + async getDownUrl() { + const winYml = 'https://ylzsfile.yikart.cn/att/latest.yml'; + const macYml = 'https://ylzsfile.yikart.cn/att/latest-mac.yml'; + + const [winYmlJson, macYmlJson] = await Promise.all([ + parseYml(winYml), + parseYml(macYml), + ]); + + return { + win: winYmlJson, + mac: macYmlJson, + hostUrl: 'https://ylzsfile.yikart.cn/att/', + }; + } + + getHello(): string { + return 'Hello!This is 爱团团AiToEarn'; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.guard.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.guard.ts new file mode 100644 index 000000000..58368f350 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.guard.ts @@ -0,0 +1,68 @@ +/* + * @Author: nevin + * @Date: 2024-12-22 21:14:15 + * @LastEditTime: 2025-02-25 21:33:04 + * @LastEditors: nevin + * @Description: + */ +import { + CanActivate, + createParamDecorator, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +export const GetToken = createParamDecorator( + async (data: string, ctx: ExecutionContext) => { + const req = ctx.switchToHttp().getRequest(); + return req['user']; + }, +); + +export const IS_PUBLIC_KEY = 'isPublic'; +export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); + +@Injectable() +export class AuthGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + // 💡 查看此条件 + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException(); + } + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: process.env.AUTH_SECRET, + }); + // 以便我们可以在路由处理器中访问它 + request['user'] = payload; + } catch { + throw new UnauthorizedException(); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.module.ts new file mode 100644 index 000000000..40c8743c6 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.module.ts @@ -0,0 +1,37 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-14 12:28:22 + * @LastEditors: nevin + * @Description: 认证 + */ +import { Global, Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthService } from './auth.service'; +import { AuthGuard } from './auth.guard'; +import { APP_GUARD } from '@nestjs/core'; +import { ManagerGuard } from './manager.guard'; + +@Global() +@Module({ + imports: [ + JwtModule.register({ + global: true, + secret: process.env.AUTH_SECRET || '550e8400-e29b-41d4-a716-446655440000', + signOptions: { expiresIn: '30d' }, + }), + ], + providers: [ + AuthService, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + { + provide: APP_GUARD, + useClass: ManagerGuard, + }, + ], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.service.ts new file mode 100644 index 000000000..d009ca5ad --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/auth.service.ts @@ -0,0 +1,58 @@ +/* + * @Author: nevin + * @Date: 2022-01-21 09:42:13 + * @LastEditors: nevin + * @LastEditTime: 2025-02-26 09:20:47 + * @Description: 认证 + */ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { TokenInfo } from './interfaces/auth.interfaces'; + +@Injectable() +export class AuthService { + constructor(private readonly jwtService: JwtService) {} + + /** + * 生成Token + * @param tokenInfo + * @returns + */ + async generateToken(tokenInfo: TokenInfo): Promise { + const payload: TokenInfo = { + phone: tokenInfo.phone, + id: tokenInfo.id, + name: tokenInfo.name, + isManager: tokenInfo.isManager, + }; + return this.jwtService.sign(payload); + } + + /** + * 重置Token + * @param tokenInfo + * @returns + */ + async resetToken(tokenInfo: TokenInfo): Promise { + const payload: TokenInfo = { + phone: tokenInfo.phone, + id: tokenInfo.id, + name: tokenInfo.name, + isManager: tokenInfo.isManager, + }; + return this.jwtService.sign(payload); + } + + async decodeToken(token: string): Promise { + token = token.replace('Bearer ', ''); + try { + return this.jwtService.decode(token); + } catch (error) { + if (error.name === 'TokenExpiredError') { + throw new UnauthorizedException('Token已过期,请重新登录'); + } else { + throw new UnauthorizedException('Token校验失败,请重新登录'); + } + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/interfaces/auth.interfaces.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/interfaces/auth.interfaces.ts new file mode 100644 index 000000000..bbe69578e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/interfaces/auth.interfaces.ts @@ -0,0 +1,21 @@ +/* + * @Author: nevin + * @Date: 2022-01-21 14:28:19 + * @LastEditors: nevin + * @LastEditTime: 2024-11-22 09:50:08 + * @Description: 认证相关接口 + */ +import { Request } from 'express'; + +export interface TokenInfo { + readonly phone?: string; + readonly id: string; + readonly name?: string; + readonly exp?: number; + readonly isManager?: boolean; + readonly mail?: string; +} + +export interface TokenReq extends Request { + tokenInfo: TokenInfo; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/manager.guard.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/manager.guard.ts new file mode 100644 index 000000000..8699a888b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/auth/manager.guard.ts @@ -0,0 +1,71 @@ +/* + * @Author: nevin + * @Date: 2025-02-06 12:10:04 + * @LastEditTime: 2025-02-06 14:10:51 + * @LastEditors: nevin + * @Description: + */ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { SetMetadata } from '@nestjs/common'; +import { IS_PUBLIC_KEY } from './auth.guard'; + +export const IS_MANAGER_KEY = 'isManager'; +export const Manager = () => SetMetadata(IS_MANAGER_KEY, true); + +@Injectable() +export class ManagerGuard implements CanActivate { + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isManager = this.reflector.getAllAndOverride( + IS_MANAGER_KEY, + [context.getHandler(), context.getClass()], + ); + + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!isManager || isPublic) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException('token不存在,需要管理员权限'); + } + + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: process.env.AUTH_SECRET, + }); + + if (!payload.isManager) { + throw new UnauthorizedException('需要管理员权限1'); + } + + request['user'] = payload; + } catch { + throw new UnauthorizedException('需要管理员权限2'); + } + return true; + } + + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/api-result.decorator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/api-result.decorator.ts new file mode 100644 index 000000000..1d670fb19 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/api-result.decorator.ts @@ -0,0 +1,97 @@ +import { + applyDecorators, + HttpStatus, + RequestMethod, + Type, +} from '@nestjs/common'; +import { METHOD_METADATA } from '@nestjs/common/constants'; +import { ApiExtraModels, ApiResponse, getSchemaPath } from '@nestjs/swagger'; + +import { ResOp } from '../model/response.model'; + +const baseTypeNames = ['String', 'Number', 'Boolean']; + +function genBaseProp(type: Type) { + if (baseTypeNames.includes(type.name)) + return { type: type.name.toLocaleLowerCase() }; + else return { $ref: getSchemaPath(type) }; +} + +/** + * @description: 生成返回结果装饰器 + */ +export function ApiResult>({ + type, + isPage, + status, +}: { + type?: TModel | TModel[]; + isPage?: boolean; + status?: HttpStatus; +}) { + let prop = null; + + if (Array.isArray(type)) { + if (isPage) { + prop = { + type: 'object', + properties: { + items: { + type: 'array', + items: { $ref: getSchemaPath(type[0]) }, + }, + meta: { + type: 'object', + properties: { + itemCount: { type: 'number', default: 0 }, + totalItems: { type: 'number', default: 0 }, + itemsPerPage: { type: 'number', default: 0 }, + totalPages: { type: 'number', default: 0 }, + currentPage: { type: 'number', default: 0 }, + }, + }, + }, + }; + } else { + prop = { + type: 'array', + items: genBaseProp(type[0]), + }; + } + } else if (type) { + prop = genBaseProp(type); + } else { + prop = { type: 'null', default: null }; + } + + const model = Array.isArray(type) ? type[0] : type; + + return applyDecorators( + ApiExtraModels(model), + ( + target: object, + key: string | symbol, + descriptor: TypedPropertyDescriptor, + ) => { + queueMicrotask(() => { + const isPost = + Reflect.getMetadata(METHOD_METADATA, descriptor.value) === + RequestMethod.POST; + + ApiResponse({ + status: status ?? (isPost ? HttpStatus.CREATED : HttpStatus.OK), + schema: { + allOf: [ + { $ref: getSchemaPath(ResOp) }, + { + properties: { + data: prop, + }, + }, + ], + }, + })(target, key, descriptor); + }); + }, + ); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/is-object-id.decorator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/is-object-id.decorator.ts new file mode 100644 index 000000000..0922024cb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/is-object-id.decorator.ts @@ -0,0 +1,32 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; +import { isValidObjectId } from 'mongoose'; + +@ValidatorConstraint({ name: 'isObjectId', async: false }) +export class IsObjectIdConstraint implements ValidatorConstraintInterface { + validate(value: any) { + if (!value) return true; + return isValidObjectId(value); + } + + defaultMessage(args: ValidationArguments) { + return `${args.property} 必须是有效的 ObjectId`; + } +} + +export function IsObjectId(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isObjectId', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: IsObjectIdConstraint, + }); + }; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/param-object-id.decorator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/param-object-id.decorator.ts new file mode 100644 index 000000000..7cecf4569 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/param-object-id.decorator.ts @@ -0,0 +1,15 @@ +import { Param, PipeTransform, BadRequestException } from '@nestjs/common'; +import { isValidObjectId } from 'mongoose'; + +export class ObjectIdPipe implements PipeTransform { + transform(value: string) { + if (!isValidObjectId(value)) { + throw new BadRequestException('Invalid ObjectId'); + } + return value; + } +} + +export function ParamObjectId(property: string = 'id') { + return Param(property, new ObjectIdPipe()); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/swagger.decorator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/swagger.decorator.ts new file mode 100644 index 000000000..92744d814 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/decorators/swagger.decorator.ts @@ -0,0 +1,11 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiSecurity } from '@nestjs/swagger'; + +export const API_SECURITY_AUTH = 'auth'; + +/** + * like to @ApiSecurity('auth') + */ +export function ApiSecurityAuth(): ClassDecorator & MethodDecorator { + return applyDecorators(ApiSecurity(API_SECURITY_AUTH)); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/delete.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/delete.dto.ts new file mode 100644 index 000000000..6f87b9e80 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/delete.dto.ts @@ -0,0 +1,8 @@ +import { IsDefined, IsNotEmpty, IsNumber } from 'class-validator'; + +export class BatchDeleteDto { + @IsDefined() + @IsNotEmpty() + @IsNumber({}, { each: true }) + ids: number[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/id.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/id.dto.ts new file mode 100644 index 000000000..271bece09 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/id.dto.ts @@ -0,0 +1,6 @@ +import { IsNumber } from 'class-validator'; + +export class IdDto { + @IsNumber() + id: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/multi-image.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/multi-image.dto.ts new file mode 100644 index 000000000..972822db9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/multi-image.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class MultiImage { + @ApiProperty({ description: '图片地址' }) + @IsString() + url: string; + + @ApiProperty({ description: '图片名称' }) + @IsString() + name: string; + + @ApiProperty({ description: '图片大小', required: false }) + @IsNumber() + @IsOptional() + size?: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/operator.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/operator.dto.ts new file mode 100644 index 000000000..01d5bcaf4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/operator.dto.ts @@ -0,0 +1,12 @@ +import { ApiHideProperty } from '@nestjs/swagger'; +import { Exclude } from 'class-transformer'; + +export class OperatorDto { + @ApiHideProperty() + @Exclude() + createBy: number; + + @ApiHideProperty() + @Exclude() + updateBy: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/pager.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/pager.dto.ts new file mode 100644 index 000000000..788788062 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/dto/pager.dto.ts @@ -0,0 +1,61 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:36:41 + * @LastEditTime: 2025-02-22 18:09:51 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform } from 'class-transformer'; +import { + Allow, + IsEnum, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export enum Order { + ASC = 'ASC', + DESC = 'DESC', +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export class PagerDto { + @ApiProperty({ minimum: 1, default: 1 }) + @Min(1) + @IsInt() + @Expose() + @IsOptional({ always: true }) + @Transform(({ value: val }) => (val ? Number.parseInt(val) : 1), { + toClassOnly: true, + }) + page: number = 1; + + @ApiProperty({ minimum: 1, maximum: 100, default: 20 }) + @Min(1) + @Max(100) + @IsInt() + @IsOptional({ always: true }) + @Expose() + @Transform(({ value: val }) => (val ? Number.parseInt(val) : 20), { + toClassOnly: true, + }) + pageSize: number = 20; + + @ApiProperty({ required: false }) + @IsString() + @IsOptional() + field?: string; + + @ApiProperty({ enum: Order, required: false }) + @IsEnum(Order) + @IsOptional() + @Transform(({ value }) => (value === 'asc' ? Order.ASC : Order.DESC)) + order?: Order; + + @Allow() + _t?: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/model/response.model.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/model/response.model.ts new file mode 100644 index 000000000..1f017806d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/model/response.model.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ResOp { + @ApiProperty({ type: 'object', additionalProperties: true }) + data?: T; + + @ApiProperty({ type: 'number', default: 200 }) + code: number; + + @ApiProperty({ type: 'string', default: '请求成功' }) + msg: string; + + constructor(code: number, data: T, message = '请求成功') { + this.code = code; + this.data = data; + this.msg = message; + } + + static success(data?: T, message?: string) { + return new ResOp(200, data, message); + } + + static error(code: number, message) { + return new ResOp(code, {}, message); + } +} + +export class TreeResult { + @ApiProperty() + id: number; + + @ApiProperty() + parentId: number; + + @ApiProperty() + children?: TreeResult[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/create-pagination.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/create-pagination.ts new file mode 100644 index 000000000..e04830ba4 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/create-pagination.ts @@ -0,0 +1,83 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:36:41 + * @LastEditTime: 2025-02-22 17:43:47 + * @LastEditors: nevin + * @Description: + */ +import { Model } from 'mongoose'; +import { + IPaginationMeta, + IPaginationOptions, + PaginationTypeEnum, +} from './interface'; +import { Pagination } from './pagination'; + +const DEFAULT_LIMIT = 20; +const DEFAULT_PAGE = 1; + +export function resolveOptions( + options: IPaginationOptions, +): [number, number, PaginationTypeEnum] { + const { page, pageSize, paginationType } = options; + + return [ + page || DEFAULT_PAGE, + pageSize || DEFAULT_LIMIT, + paginationType || PaginationTypeEnum.TAKE_AND_SKIP, + ]; +} + +export function createPaginationObject({ + items, + totalItems, + currentPage, + limit, +}: { + items: T[]; + totalItems?: number; + currentPage: number; + limit: number; +}): Pagination { + const totalPages = + totalItems !== undefined ? Math.ceil(totalItems / limit) : undefined; + + const meta: IPaginationMeta = { + totalItems, + itemCount: items.length, + itemsPerPage: +limit, + totalPages, + currentPage: +currentPage, + }; + + return new Pagination(items, meta); +} + +export async function paginateModel( + model: Model, + options: IPaginationOptions, + condition: any, + populate?: any, + sort?: any, +): Promise> { + const [page, limit] = resolveOptions(options); + + const promises: [Promise, Promise | undefined] = [ + model + .find(condition) + .skip(limit * (page - 1)) + .limit(limit) + .populate(populate) + .sort(sort), + model.countDocuments(condition), + ]; + + const [items, total] = await Promise.all(promises); + + return createPaginationObject({ + items, + totalItems: total, + currentPage: page, + limit, + }); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/interface.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/interface.ts new file mode 100644 index 000000000..5f88221cb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/interface.ts @@ -0,0 +1,25 @@ +export enum PaginationTypeEnum { + LIMIT_AND_OFFSET = 'limit', + TAKE_AND_SKIP = 'take', +} + +export interface IPaginationOptions { + page: number; + pageSize: number; + paginationType?: PaginationTypeEnum; +} + +export interface IPaginationMeta { + itemCount: number; + totalItems?: number; + itemsPerPage: number; + totalPages?: number; + currentPage: number; +} + +export interface IPaginationLinks { + first?: string; + previous?: string; + next?: string; + last?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/pagination.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/pagination.ts new file mode 100644 index 000000000..ed28de2de --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/paginate/pagination.ts @@ -0,0 +1,15 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:36:41 + * @LastEditTime: 2025-02-22 17:47:03 + * @LastEditors: nevin + * @Description: + */ +import { IPaginationMeta } from './interface'; + +export class Pagination { + constructor( + public readonly items: T[], + public readonly meta: IPaginationMeta, + ) {} +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/date-range.validator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/date-range.validator.ts new file mode 100644 index 000000000..03eb22d33 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/date-range.validator.ts @@ -0,0 +1,22 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isDateRange', async: false }) +export class IsDateRange implements ValidatorConstraintInterface { + validate(propertyValue: Date, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + + if (!propertyValue || !relatedValue) return true; + + return propertyValue < relatedValue; + } + + defaultMessage(args: ValidationArguments) { + const [relatedPropertyName] = args.constraints; + return `${args.property}必须早于${relatedPropertyName}`; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/image-url.validator.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/image-url.validator.ts new file mode 100644 index 000000000..ef43fcb41 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/common/validators/image-url.validator.ts @@ -0,0 +1,31 @@ +import { + registerDecorator, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isValidImageUrl', async: false }) +class IsValidImageUrlConstraint implements ValidatorConstraintInterface { + validate(value: string) { + if (!value) return false; + + // 检查是否是有效的图片URL + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']; + return imageExtensions.some((ext) => value.toLowerCase().endsWith(ext)); + } + + defaultMessage(args: ValidationArguments) { + return `${args.property}必须是有效的图片URL地址`; + } +} + +export function IsValidImageUrl() { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + validator: IsValidImageUrlConstraint, + }); + }; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/db-mongo.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/db-mongo.module.ts new file mode 100644 index 000000000..4ee03ebdb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/db-mongo.module.ts @@ -0,0 +1,35 @@ +/* + * @Author: nevin + * @Date: 2022-09-23 18:00:51 + * @LastEditTime: 2025-01-15 14:20:46 + * @LastEditors: nevin + * @Description: + */ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { MongooseModule } from '@nestjs/mongoose'; +import mongoConfig from '../../config/mongo.config'; +import { Id, IdSchema } from './id.schema'; +import { IdService } from './id.service'; + +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [mongoConfig], // 加载配置 + }), + MongooseModule.forRootAsync({ + imports: [ConfigModule], // 数据库配置项依赖于ConfigModule,需在此引入 + inject: [ConfigService], + useFactory: (configService: ConfigService) => + configService.get('MONGO_CONFIG'), + }), + MongooseModule.forFeature([ + // 挂载实体 + { name: Id.name, schema: IdSchema }, + ]), + ], + providers: [IdService], + exports: [IdService], +}) +export class DbMongoModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.schema.ts new file mode 100644 index 000000000..3c624fd58 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.schema.ts @@ -0,0 +1,23 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:46:31 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:32 + * @Description: 自增主键ID + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; + +export type IdDocument = Id & Document; +@Schema({ collection: 't_ids', versionKey: false }) +export class Id { + @Prop({ required: true }) + id_value: number; + + @Prop({ required: true }) + id_name: string; + + @Prop() + update_time: number; +} +export const IdSchema = SchemaFactory.createForClass(Id); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.service.ts new file mode 100644 index 000000000..f657eca7f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/id.service.ts @@ -0,0 +1,54 @@ +/* + * @Author: nevin + * @Date: 2021-12-24 13:49:52 + * @LastEditors: nevin + * @LastEditTime: 2024-08-30 15:01:55 + * @Description: 自增ID + */ +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { Injectable } from '@nestjs/common'; +import { Id, IdDocument } from './id.schema'; + +@Injectable() +export class IdService { + constructor( + @InjectModel(Id.name) private readonly idModel: Model, + ) {} + + /** + * @description: 创建id + * @param {string} id_name id名称 + * @param {number} id_value id + * @return: Promise + */ + public async createId( + id_name: string, + id_value: number, + id_type: T, + ): Promise { + const fadArgs = { + query: { + id_name, + }, + update: { + $inc: { id_value: 1 }, + $set: { update_time: new Date() }, + }, + options: { new: true }, + }; + let newId = await this.idModel + .findOneAndUpdate(fadArgs.query, fadArgs.update, fadArgs.options) + .exec(); + if (newId) { + return newId.id_value; + } + const createdUser = new this.idModel({ id_name, id_value }); + newId = await createdUser.save(); + + const id = + typeof id_type === 'string' ? newId.id_value.toString() : newId.id_value; + + return id; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/account.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/account.schema.ts new file mode 100644 index 000000000..718c2ffab --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/account.schema.ts @@ -0,0 +1,171 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { Types } from 'mongoose'; + +// 平台类型 +export enum AccountType { + Douyin = 'douyin', // 抖音 + Xhs = 'xhs', // 小红书 + WxSph = 'wxSph', // 微信视频号 + KWAI = 'KWAI', // 快手 + YOUTUBE = 'youtube', // youtube + TWITTER = 'twitter', // twitter + TIKTOK = 'tiktok', // tiktok +} + +// 账号状态 +export enum AccountStatus { + // 未失效 + USABLE = 0, + // 失效 + DISABLE = 1, +} + +@Schema({ + collection: 'account', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Account extends TimeTemp { + @Prop({ + required: true, + unique: true, + type: Number, + index: true, + }) + id: number; + + @Prop({ + required: true, + type: String, + }) + userId: string; + + @Prop({ + required: true, + enum: AccountType, + }) + type: AccountType; + + @Prop({ + required: true, + type: String, + }) + loginCookie: string; + + @Prop({ + required: false, + type: String, + default: '', + }) + token: string; // 其他token 目前抖音用 + + @Prop({ + required: false, + type: Date, + }) + loginTime?: Date; + + @Prop({ + required: true, + }) + uid: string; + + @Prop({ + required: true, + }) + account: string; + + @Prop({ + required: true, + }) + avatar: string; + + @Prop({ + required: true, + }) + nickname: string; + + @Prop({ + required: true, + default: 0, + }) + fansCount: number; + + @Prop({ + required: true, + default: 0, + }) + readCount: number; + + @Prop({ + required: true, + default: 0, + }) + likeCount: number; + + @Prop({ + required: true, + default: 0, + }) + collectCount: number; + + @Prop({ + required: true, + default: 0, + }) + forwardCount: number; + + @Prop({ + required: true, + default: 0, + }) + commentCount: number; + + @Prop({ + required: false, + type: Date, + }) + lastStatsTime?: Date; + + @Prop({ + required: true, + default: 0, + }) + workCount: number; + + @Prop({ + required: true, + default: 0, + }) + income: number; + + // 账户关联组,与 accountGroup.id 关联 + @Prop({ type: Number, required: true }) + groupId: number; + + @Prop({ + required: true, + default: AccountStatus.USABLE, + }) + status: AccountStatus; // 登录状态,用于判断是否失效 + + @Prop({ + required: false, + }) + googleId: string; + + // @Prop({ + // required: false, + // }) + // accessToken: string; + + // @Prop({ + // required: false, + // }) + // refreshToken: string; +} + +export const AccountSchema = SchemaFactory.createForClass(Account); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountGroup.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountGroup.schema.ts new file mode 100644 index 000000000..608b018ce --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountGroup.schema.ts @@ -0,0 +1,58 @@ +// 账户组默认ID +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + +import { TimeTemp } from './time.tamp'; + +// 默认用户组类型 +export enum AccountGroupDefaultType { + Default = 0, + NonDefault = 1, +} + +@Schema({ + collection: 'accountGroup', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class AccountGroup extends TimeTemp { + @Prop({ + unique: true, + index: true, + type: Number, + required: true, + }) + id: number; + + @Prop({ + required: true, + type: String, + }) + userId: string; + + // 是否为默认用户组 + @Prop({ + required: true, + type: Number, + default: AccountGroupDefaultType.NonDefault, + }) + isDefault: number; + + // 组名称 + @Prop({ + required: true, + type: String, + }) + name: string; + + // 组排序 + @Prop({ + required: true, + type: Number, + default: 1, + }) + rank: number; +} + +export const AccountGroupSchema = SchemaFactory.createForClass(AccountGroup); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountToken.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountToken.schema.ts new file mode 100644 index 000000000..298916bd7 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/accountToken.schema.ts @@ -0,0 +1,83 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { Types } from 'mongoose'; + +// 平台类型 +export enum TokenPlatform { + Douyin = 'douyin', // 抖音 + Xhs = 'xhs', // 小红书 + WxSph = 'wxSph', // 微信视频号 + KWAI = 'KWAI', // 快手 + YOUTUBE = "youtube", // youtube + TWITTER = "twitter", // twitter + TIKTOK = "tiktok", // tiktok +} + +// 账号状态 +export enum TokenStatus { + // 未失效 + USABLE = 0, + // 失效 + DISABLE = 1, +} + +@Schema({ + collection: 'accountToken', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class AccountToken extends TimeTemp { + + @Prop({ + required: true, + type: String, + }) + userId: string; + + @Prop({ + required: true, + enum: TokenPlatform, + }) + platform: TokenPlatform; + + @Prop({ + required: true, + type: String, + default: '', + }) + refreshToken: string; + + @Prop({ + required: true, + unique: true, + }) + accountId: string; + + @Prop({ + required: true, + default: TokenStatus.USABLE, + }) + status: TokenStatus; + + @Prop({ + required: true, + type: Date, + }) + createTime: Date; + + @Prop({ + required: true, + type: Date, + }) + expiresAt: Date; + + @Prop({ + required: true, + type: Date, + }) + updateTime: Date; +} + +export const AccountTokenSchema = SchemaFactory.createForClass(AccountToken); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/banner.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/banner.schema.ts new file mode 100644 index 000000000..383f23b18 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/banner.schema.ts @@ -0,0 +1,48 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2024-11-27 13:01:10 + * @LastEditors: nevin + * @Description: banner Banner + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { ONOFF } from 'src/global/enum/all.enum'; + +export enum BannerTag { + HOME = 'home', +} + +@Schema({ + collection: 'banner', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Banner extends TimeTemp { + id: string; + + @Prop({ required: false, comment: '数据ID' }) + dataId: string; + + @Prop({ required: false, comment: '描述' }) + desc: string; + + @Prop({ required: false, comment: '链接' }) + url: string; + + @Prop({ required: true, comment: '图片链接' }) + imgUrl: string; + + @Prop({ required: true, comment: '标识', enum: BannerTag }) + tag: BannerTag; + + @Prop({ + required: true, + comments: '是否发布', + enum: ONOFF, + }) + isPublish: ONOFF; +} + +export const BannerSchema = SchemaFactory.createForClass(Banner); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/cfg.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/cfg.schema.ts new file mode 100644 index 000000000..9c5583fcb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/cfg.schema.ts @@ -0,0 +1,67 @@ +/* + * @Author: nevin + * @Date: 2025-02-18 22:32:02 + * @LastEditTime: 2025-03-04 15:23:22 + * @LastEditors: nevin + * @Description: 系统配置模块 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { ONOFF } from 'src/global/enum/all.enum'; + +export enum CfgType { + comment = 'comment', // 通用 +} + +@Schema({ + collection: 'cfg', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Cfg extends TimeTemp { + id: string; + + @Prop({ + required: true, + type: String, + unique: true, + }) + key: string; + + @Prop({ + required: true, + type: String, + }) + title: string; + + @Prop({ + required: true, + type: Object, + }) + content: any; + + @Prop({ + required: true, + enum: CfgType, + default: CfgType.comment, + }) + type: CfgType; + + @Prop({ + required: true, + enum: ONOFF, + default: ONOFF.ON, + }) + status: ONOFF; + + @Prop({ + required: false, + type: String, + default: '', + }) + desc?: string; +} + +export const CfgSchema = SchemaFactory.createForClass(Cfg); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/feedback.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/feedback.schema.ts new file mode 100644 index 000000000..f603d97db --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/feedback.schema.ts @@ -0,0 +1,60 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2024-11-22 09:53:38 + * @LastEditors: nevin + * @Description: 反馈 Feedback feedback + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum FeedbackType { + errReport = 'errReport', // 错误反馈 + feedback = 'feedback', // 反馈 + msgReport = 'msgReport', // 消息举报 + msgFeedback = 'msgFeedback', // 消息反馈 +} + +@Schema({ + collection: 'feedback', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class Feedback extends TimeTemp { + id: string; + + @Prop({ required: true }) + userId: string; + + @Prop({ comment: '用户名' }) + userName: string; + + @Prop({ + comment: '内容', + default: '', + }) + content: string; + + @Prop({ + default: FeedbackType.feedback, + enum: FeedbackType, + }) + type: FeedbackType; + + @Prop({ + comments: '标识数组', + type: [String], + default: [], + }) + tagList?: string[]; + + @Prop({ + comments: '文件链接数组', + type: [String], + default: [], + }) + fileUrlList: string[]; +} + +export const FeedbackSchema = SchemaFactory.createForClass(Feedback); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/manager.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/manager.schema.ts new file mode 100644 index 000000000..212043bec --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/manager.schema.ts @@ -0,0 +1,42 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum ManagerStatus { + STOP = 0, // 停用 + OPEN = 1, // 正常 + DELETE = 2, // 删除 +} + +@Schema({ + collection: 'manager', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Manager extends TimeTemp { + id: string; + + @Prop({ required: true, unique: true }) + account: string; + + @Prop({ required: true }) + password: string; + + @Prop({ required: true }) + salt: string; + + @Prop({ required: true }) + name: string; + + @Prop({ default: ManagerStatus.OPEN }) + status: ManagerStatus; + + @Prop() + avatar?: string; + + @Prop() + phone?: string; +} + +export const ManagerSchema = SchemaFactory.createForClass(Manager); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/pubRecord.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/pubRecord.schema.ts new file mode 100644 index 000000000..3b75296cc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/pubRecord.schema.ts @@ -0,0 +1,95 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum PubType { + VIDEO = 'video', // 视频 + ARTICLE = 'article', // 文章 + ImageText = 'image-text', // 图文 +} + +export enum PubStatus { + UNPUBLISH = 0, // 未发布/草稿 + RELEASED = 1, // 已发布 + FAIL = 2, // 发布失败 + PartSuccess = 3, // 部分成功 +} + +@Schema({ + collection: 'pubRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class PubRecord extends TimeTemp { + @Prop({ + required: true, + unique: true, + type: Number, + }) + id: number; + + @Prop({ + required: true, + }) + userId: string; + + @Prop({ + required: true, + enum: PubType, + }) + type: PubType; + + @Prop({ + required: true, + default: '', + }) + title: string; + + @Prop({ + required: true, + default: '', + }) + desc: string; + + @Prop({ + required: true, + }) + accountId: number; + + @Prop({ + required: false, + }) + videoPath?: string; + + @Prop({ + required: false, + type: Date, + }) + timingTime?: Date; // 定时发布日期 + + @Prop({ + required: false, + }) + coverPath?: string; // '封面路径,展示给前台用 + + @Prop({ + required: false, + }) + commonCoverPath?: string; // 通用封面路径 + + @Prop({ + required: true, + type: Date, + }) + publishTime: Date; + + @Prop({ + required: true, + enum: PubStatus, + default: PubStatus.UNPUBLISH, + }) + status: PubStatus; +} + +export const PubRecordSchema = SchemaFactory.createForClass(PubRecord); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/qaRecord.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/qaRecord.schema.ts new file mode 100644 index 000000000..b2a0954ce --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/qaRecord.schema.ts @@ -0,0 +1,54 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-04-14 17:40:15 + * @LastEditors: nevin + * @Description: 反馈 Feedback feedback + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum QaRecordType { + QA = 'qa', +} + +@Schema({ + collection: 'qaRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class QaRecord extends TimeTemp { + id: string; + + @Prop({ comment: '标题', nullable: false }) + title: string; + + @Prop({ + comment: '内容', + nullable: false, + }) + content: string; + + @Prop({ + enum: QaRecordType, + comment: '类型', + default: QaRecordType.QA, + }) + type: QaRecordType; + + @Prop({ + comments: '标识数组', + type: [String], + default: [], + }) + tagList: string[]; + + @Prop({ + comment: '排序', + default: 0, + }) + sort?: number; +} + +export const QaRecordSchema = SchemaFactory.createForClass(QaRecord); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/realAuth.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/realAuth.schema.ts new file mode 100644 index 000000000..3ce46ccce --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/realAuth.schema.ts @@ -0,0 +1,42 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2024-11-27 13:01:10 + * @LastEditors: nevin + * @Description: realAuth RealAuth + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum SceneType { + Login = 'login', + UserWallet = 'userWallet', + Register = 'register', +} + +@Schema({ + collection: 'realAuth', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class RealAuth extends TimeTemp { + id: string; + + @Prop({ required: true, comment: '用户ID' }) + userId: string; + + @Prop({ required: true, comment: '姓名' }) + userName: string; + + @Prop({ required: true, comment: '身份证号' }) + identifyNum: string; + + @Prop({ required: false, comment: '数据ID' }) + dataId?: string; + + @Prop({ required: false, comment: '场景类型', enum: SceneType }) + sceneType?: SceneType; +} + +export const RealAuthSchema = SchemaFactory.createForClass(RealAuth); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/signIn.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/signIn.schema.ts new file mode 100644 index 000000000..1ec4e1889 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/signIn.schema.ts @@ -0,0 +1,37 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-04-27 17:34:58 + * @LastEditors: nevin + * @Description: signIn SignIn + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum SignInType { + PUL_VIDEO = 'pul_video', +} + +@Schema({ + collection: 'signIn', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class SignIn extends TimeTemp { + id: string; + + @Prop({ required: true, comment: '用户ID', index: true }) + userId: string; + + @Prop({ required: true, comment: '类型', enum: SignInType }) + type: SignInType; + + @Prop({ required: false, comment: '触发时的数据ID' }) + dataId?: string; + + @Prop({ required: false, comment: '描述' }) + desc?: string; +} + +export const SignInSchema = SchemaFactory.createForClass(SignIn); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/statement.shema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/statement.shema.ts new file mode 100644 index 000000000..447c9afac --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/statement.shema.ts @@ -0,0 +1,33 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-02-27 15:18:16 + * @LastEditors: nevin + * @Description: 用户流水 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { Decimal128 } from 'mongodb'; + +@Schema({ + collection: 'statement', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Statement extends TimeTemp { + @Prop({ + required: true, + }) + userId: string; + + @Prop({ + type: Decimal128, + required: true, + default: 0, + }) + balance: Decimal128; +} + +export const StatementSchema = SchemaFactory.createForClass(Statement); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/task.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/task.schema.ts new file mode 100644 index 000000000..b77ce1f1a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/task.schema.ts @@ -0,0 +1,218 @@ +/* + * @Author: nevin + * @Date: 2025-02-18 22:32:02 + * @LastEditTime: 2025-03-04 15:23:22 + * @LastEditors: nevin + * @Description: 任务 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { AccountType } from './account.schema'; + +export enum TaskType { + PRODUCT = 'product', // 商品任务 + VIDEO = 'video', // 视频任务 + ARTICLE = 'article', // 文章任务 + PROMOTION = 'promotion', // 拉新任务 + INTERACTION = 'interaction', // 互动任务 +} + +export enum TaskStatus { + ACTIVE = 'active', // 进行中 + CANCELLED = 'cancelled', // 已取消 + DEL = 'del', // 已删除 +} + +export interface TaskFile { + name: string; + url: string; +} + +export class TaskFileSchema implements TaskFile { + @Prop({ + required: true, + comment: '指标名称', + }) + name: string; + + @Prop({ + required: true, + comment: '指标值', + }) + url: string; +} + +@Schema({ toJSON: { virtuals: true }, toObject: { virtuals: true } }) +export class TaskData { + @Prop({ required: false }) + title?: string; + + @Prop({ required: false }) + desc?: string; +} +// 视频 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskVideo extends TaskData { + @Prop({ required: false }) + coverUrl?: string; // 封面图 + + @Prop({ required: true }) + videoUrl: string; + + @Prop({ required: false, type: [String], default: [] }) + topicList: string[]; +} + +// 文章 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskArticleImg { + @Prop({ required: true, default: '' }) + content: string; // 内容 + + @Prop({ required: true }) + imageUrl: string; +} +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskArticleMaterial { + @Prop({ required: false }) + coverUrl?: string; // 封面图 + + @Prop({ required: false }) + title?: string; // 标题 + + @Prop({ required: true }) + temp: string; // 模板字符 + + // 图片列表 + @Prop({ required: true, type: [TaskArticleImg], default: [] }) + imageList: TaskArticleImg[]; +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskArticle extends TaskData { + @Prop({ required: true, type: [TaskArticleMaterial], default: [] }) + materialList: TaskArticleMaterial[]; // 素材列表 + + @Prop({ required: false, type: [String], default: [] }) + topicList: string[]; + + // 图片列表 + @Prop({ required: true, type: [String], default: [] }) + imageList: string[]; +} + +// 推广 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskPromotion extends TaskData {} + +// 商品 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskProduct extends TaskData { + @Prop({ required: true }) // 商品价格 + price: number; + + // 销量 + @Prop({ required: false }) + sales?: number; +} + +// 互动 +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskInteraction extends TaskData { + @Prop({ required: true, enum: AccountType }) + accountType: AccountType; // 平台类型 + + @Prop({ required: true, type: String }) + worksId: string; // 作品ID + + @Prop({ required: false, type: String }) + authorId?: string; // 作者ID + + @Prop({ required: false, type: String }) + commentContent?: string; // 评论内容,不填则使用AI +} + +@Schema({ + collection: 'task', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Task extends TimeTemp { + id: string; + + @Prop({ required: true }) + title: string; + + @Prop({ + required: true, + }) + description: string; // md文档 + + @Prop({ required: true, enum: TaskType }) + type: TaskType; + + @Prop({ + required: false, + }) + imageUrl: string; // 配图 + + @Prop({ type: Object, required: false }) + dataInfo: + | TaskVideo + | TaskPromotion + | TaskProduct + | TaskInteraction + | TaskArticle; + + @Prop({ type: [TaskFileSchema], default: [] }) + fileList: TaskFileSchema[]; // 附件地址列表 + + @Prop({ required: true, default: 0 }) + keepTime: number; // 保持时间(秒) + + @Prop({ default: false }) + requiresShoppingCart: boolean; // 是否需要挂购物车 + + @Prop({ required: true }) + maxRecruits: number; // 最大招募人数 + + @Prop({ default: 0 }) + currentRecruits: number; // 当前招募人数 + + @Prop({ required: true }) + deadline: Date; // 任务截止时间 + + @Prop({ required: true, type: Number }) + reward: number; // 任务奖励金额 + + @Prop({ default: TaskStatus.CANCELLED, enum: TaskStatus }) + status: TaskStatus; + + @Prop({ type: Array, required: true }) + accountTypes: AccountType[]; // 支持的平台tag列表 +} + +export const TaskSchema = SchemaFactory.createForClass(Task); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/taskMaterial.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/taskMaterial.schema.ts new file mode 100644 index 000000000..58c56c183 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/taskMaterial.schema.ts @@ -0,0 +1,63 @@ +/* + * @Author: nevin + * @Date: 2025-02-18 22:32:02 + * @LastEditTime: 2025-03-04 15:23:22 + * @LastEditors: nevin + * @Description: 任务-素材 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { TaskType } from './task.schema'; + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class TaskArticleImg { + @Prop({ required: false, default: '' }) + content?: string; // 内容 + + @Prop({ required: true }) + imageUrl: string; +} + +@Schema({ + collection: 'taskMaterial', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class TaskMaterial extends TimeTemp { + id: string; + + @Prop({ required: true, index: true }) + taskId: string; + + @Prop({ required: true, enum: TaskType }) + type: TaskType; + + @Prop({ required: false }) + title?: string; // 标题 + + @Prop({ required: false }) + coverUrl?: string; // 封面图 + + @Prop({ required: false }) + temp?: string; // 模板字符 + + @Prop({ + required: false, + }) + desc?: string; + + // 图片列表 + @Prop({ required: true, type: [TaskArticleImg], default: [] }) + imageList: TaskArticleImg[]; + + // 已使用次数 + @Prop({ required: true, default: 0 }) + usedCount: number; +} + +export const TaskMaterialSchema = SchemaFactory.createForClass(TaskMaterial); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/time.tamp.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/time.tamp.ts new file mode 100644 index 000000000..bf64ccf74 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/time.tamp.ts @@ -0,0 +1,16 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-02-22 12:37:22 + * @LastEditors: nevin + * @Description: 时间模板 + */ +import { Prop } from '@nestjs/mongoose'; + +export class TimeTemp { + @Prop({ default: Date.now }) + createTime: Date; + + @Prop({ default: Date.now, set: () => new Date() }) + updateTime: Date; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/tracing.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/tracing.schema.ts new file mode 100644 index 000000000..a38206be6 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/tracing.schema.ts @@ -0,0 +1,70 @@ +/* + * @Author: nevin + * @Date: 2025-02-18 22:32:02 + * @LastEditTime: 2025-03-04 15:23:22 + * @LastEditors: nevin + * @Description: 跟踪 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { SchemaTypes } from 'mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum TracingType { + EVENT = 'event', // 事件 + ERROR = 'error', // 错误收集 +} + +@Schema({ + collection: 'tracing', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Tracing extends TimeTemp { + id: string; + + @Prop({ + required: true, + type: String, + index: true, + }) + userId: string; + + @Prop({ + required: true, + enum: TracingType, + }) + type: TracingType; + + @Prop({ + required: true, + type: String, + index: true, + }) + tag: string; + + @Prop({ + required: false, + type: Number, + }) + accountId?: number; // 平台账号ID + + @Prop({ + required: false, + }) + desc?: string; + + @Prop({ + required: false, + }) + dataId?: string; // 关联数据id + + @Prop({ + required: false, + type: SchemaTypes.Mixed, + }) + data?: any; // 支持任意类型 +} + +export const TracingSchema = SchemaFactory.createForClass(Tracing); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user-task.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user-task.schema.ts new file mode 100644 index 000000000..723019987 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user-task.schema.ts @@ -0,0 +1,127 @@ +/* + * @Author: nevin + * @Date: 2024-09-02 14:45:57 + * @LastEditTime: 2025-05-06 14:15:29 + * @LastEditors: nevin + * @Description: 用户的任务 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Types } from 'mongoose'; +import { TimeTemp } from './time.tamp'; +import { AccountType } from './account.schema'; + +export enum UserTaskStatus { + DODING = 'doing', // 进行中 + PENDING = 'pending', // 待审核 + APPROVED = 'approved', // 已通过 + REJECTED = 'rejected', // 已拒绝 + CANCELLED = 'cancelled', // 已取消 + DEL = 'del', // 已删除或回退 +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class AutoData { + @Prop({ + required: true, + }) + status: -1 | 0 | 1; + + @Prop({ required: false }) + message?: string; +} + +@Schema({ + collection: 'user_task', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class UserTask extends TimeTemp { + id: string; + + @Prop({ type: Types.ObjectId, ref: 'User', required: true, index: true }) + userId: Types.ObjectId; + + @Prop({ type: Types.ObjectId, ref: 'Task', required: true }) + taskId: Types.ObjectId; + + @Prop({ + type: String, + enum: UserTaskStatus, + default: UserTaskStatus.DODING, + index: true, + }) + status: UserTaskStatus; + + @Prop({ + required: true, + enum: AccountType, + }) + accountType: AccountType; + + @Prop({ + required: true, + }) + uid: string; // 平台的用户ID + + @Prop({ + required: true, + }) + account: string; // 平台的账号 + + @Prop({ required: true, default: 0 }) + keepTime: number; // 保持时间(秒) + + @Prop() + submissionUrl?: string; // 提交的视频、文章或截图URL + + @Prop({ + required: false, + }) + taskMaterialId?: string; // 任务的素材ID + + @Prop({ type: [String] }) + screenshotUrls?: string[]; // 任务完成截图 + + @Prop() + qrCodeScanResult?: string; // 二维码扫描结果 + + @Prop() + submissionTime?: Date; // 提交时间 + + @Prop() + completionTime?: Date; // 完成时间 + + @Prop() + rejectionReason?: string; // 拒绝原因 + + @Prop({ type: Object }) + metadata?: Record; // 额外信息,如审核反馈等 + + @Prop({ default: false }) + isFirstTimeSubmission: boolean; // 是否首次提交,用于确定是否给予首次奖励 + + @Prop() + verificationNote?: string; // 人工核查备注 + + @Prop({ + required: true, + default: 0, + }) + reward: number; // 奖励金额 + + @Prop() + rewardTime?: Date; // 奖励发放时间 + + @Prop({ type: Types.ObjectId, ref: 'User' }) + verifiedBy?: Types.ObjectId; // 核查人员ID + + @Prop({ type: Object, required: false, default: {} }) + autoData?: AutoData; +} + +export const UserTaskSchema = SchemaFactory.createForClass(UserTask); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user.schema.ts new file mode 100644 index 000000000..f10fd846f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/user.schema.ts @@ -0,0 +1,149 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-05-06 15:50:05 + * @LastEditors: nevin + * @Description: 用户 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +export enum UserStatus { + STOP = 0, + OPEN = 1, + DELETE = -1, +} + +export enum EarnInfoStatus { + CLOSE = 0, + OPEN = 1, +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class UserBackData { + @Prop({ + required: false, + }) + phone?: string; + + @Prop({ required: false }) + wxOpenid?: string; + + @Prop({ required: false }) + wxUnionid?: string; +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class UserEarnInfo { + @Prop({ + required: true, + enum: EarnInfoStatus, + default: EarnInfoStatus.OPEN, + }) + status: EarnInfoStatus; + + @Prop({ required: true }) + cycleInterval: number; +} + +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, +}) +export class GoogleAccount { + @Prop({ required: true }) + googleId: string; + + @Prop({ required: true }) + email: string; + + @Prop() + refreshToken: string; + + @Prop() + expiresAt?: number; +} + +@Schema({ + collection: 'user', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class User extends TimeTemp { + id: string; + + @Prop({ + required: true, + default: '', + }) + name: string; + + @Prop({ + required: false, + index: true, + unique: true, + }) + mail?: string; + + @Prop({ type: Object, required: false, default: {} }) + googleAccount?: GoogleAccount; + + @Prop({ + required: false, + index: true, + unique: true, + }) + googleId?: string; + + @Prop({ + required: false, + }) + phone?: string; + + @Prop({ + required: false, + }) + password?: string; + + @Prop({ + required: false, + }) + salt?: string; + + @Prop({ + required: true, + enum: UserStatus, + default: UserStatus.OPEN, + }) + status: UserStatus; + + @Prop({ required: false }) + wxOpenid?: string; + + @Prop({ required: false }) + wxUnionid?: string; + + @Prop({ required: false }) + popularizeCode?: string; // 我的推广码 + + @Prop({ required: false }) + inviteUserId?: string; // 邀请人用户ID + + @Prop({ required: false }) + inviteCode?: string; // 我填写的邀请码 + + @Prop({ type: Object, required: false, default: {} }) + backData?: UserBackData; + + @Prop({ type: Object, required: false, default: {} }) + earnInfo?: UserEarnInfo; +} + +export const UserSchema = SchemaFactory.createForClass(User); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWallet.shema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWallet.shema.ts new file mode 100644 index 000000000..1b42b882f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWallet.shema.ts @@ -0,0 +1,41 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-03-24 20:56:59 + * @LastEditors: nevin + * @Description: 用户钱包 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { Decimal128 } from 'mongodb'; + +@Schema({ + collection: 'userWallet', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class UserWallet extends TimeTemp { + @Prop({ + required: true, + }) + userId: string; + + @Prop({ + type: Decimal128, + required: true, + default: 0, + }) + balance: Decimal128; // 余额 + + // 收入 + @Prop({ + type: Decimal128, + required: true, + default: 0, + }) + income: Decimal128; +} + +export const UserWalletSchema = SchemaFactory.createForClass(UserWallet); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletAccount.shema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletAccount.shema.ts new file mode 100644 index 000000000..1916862ed --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletAccount.shema.ts @@ -0,0 +1,65 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-04-27 12:11:39 + * @LastEditors: nevin + * @Description: 用户钱包账户 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; + +export enum WalletAccountType { + ZFB = 'ZFB', + WX_PAY = 'WX_PAY', +} + +@Schema({ + collection: 'userWalletAccount', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class UserWalletAccount extends TimeTemp { + id: string; + + @Prop({ + required: true, + }) + userId: string; + + @Prop({ + required: true, + }) + userName: string; // 真实姓名 + + @Prop({ + required: false, + }) + account?: string; // 账号 + + @Prop({ + required: true, + }) + cardNum: string; // 身份证号 + + @Prop({ + required: true, + }) + phone: string; // 绑定的手机号 + + @Prop({ + required: true, + enum: WalletAccountType, + }) + type: WalletAccountType; + + @Prop({ + required: true, + default: false, + }) + isDef: boolean; // 是否默认 +} + +export const UserWalletAccountSchema = + SchemaFactory.createForClass(UserWalletAccount); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletRecord.shema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletRecord.shema.ts new file mode 100644 index 000000000..00640017b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/userWalletRecord.shema.ts @@ -0,0 +1,93 @@ +/* + * @Author: nevin + * @Date: 2022-11-16 22:04:18 + * @LastEditTime: 2025-03-24 21:44:35 + * @LastEditors: nevin + * @Description: 钱包收支记录 + */ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { Decimal128 } from 'mongodb'; +import { Types } from 'mongoose'; +import { UserWalletAccount } from './userWalletAccount.shema'; + +export enum UserWalletRecordType { + TASK_COMMISSION = 'TASK_COMMISSION', // 任务佣金 + WITHDRAW = 'WITHDRAW', // 提现 +} + +export enum UserWalletRecordStatus { + FAIL = -1, + WAIT = 0, + SUCCESS = 1, +} +@Schema({ + collection: 'userWalletRecord', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class UserWalletRecord extends TimeTemp { + id: string; + + @Prop({ + index: true, + required: true, + }) + userId: string; + + @Prop({ + type: Types.ObjectId, + ref: UserWalletAccount.name, + required: true, + }) + account: Types.ObjectId; + + @Prop({ + index: true, + required: false, + }) + dataId?: string; // 关联数据的ID + + @Prop({ + index: true, + required: true, + enum: UserWalletRecordType, + }) + type: UserWalletRecordType; + + @Prop({ + index: true, + type: Decimal128, + required: true, + default: 0, + }) + balance: Decimal128; + + // 状态 -1 失败 0 等待 1 完成 + @Prop({ + index: true, + required: true, + default: UserWalletRecordStatus.WAIT, + }) + status: UserWalletRecordStatus; + + @Prop({ + required: false, + }) + payTime?: Date; + + @Prop({ + required: false, + }) + des?: string; + + @Prop({ + required: false, + }) + imgUrl?: string; // 反馈截图 +} + +export const UserWalletRecordSchema = + SchemaFactory.createForClass(UserWalletRecord); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/video.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/video.schema.ts new file mode 100644 index 000000000..b7014523b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/video.schema.ts @@ -0,0 +1,18 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { WorkData } from './workData.schema'; + +@Schema({ + collection: 'video', + versionKey: false, + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: false, +}) +export class Video extends WorkData { + @Prop({ + required: true, + }) + videoPath: string; // 视频路径 +} + +export const VideoSchema = SchemaFactory.createForClass(Video); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/workData.schema.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/workData.schema.ts new file mode 100644 index 000000000..f70e8a7e8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/db/schema/workData.schema.ts @@ -0,0 +1,262 @@ +import { Prop, Schema } from '@nestjs/mongoose'; +import { TimeTemp } from './time.tamp'; +import { AccountType } from './account.schema'; + +export enum PubStatus { + UNPUBLISH = 0, // 未发布/草稿 + RELEASED = 1, // 已发布 + FAIL = 2, // 发布失败 +} + +export interface ILableValue { + label: string; + value: string | number; +} + +// 地点数据 +export interface ILocationDataItem { + // 地点名称 + name: string; + // 简单地址简介 + simpleAddress: string; + // 地址ID + id: string; + // 小红书特有 + poi_type?: number; + latitude: number; + longitude: number; + // 市 + city: string; +} + +export interface WxSphEvent { + eventCreatorNickname: string; + eventTopicId: string; + eventName: string; +} + +export enum DeclarationDouyin { + // 内容由AI生成 + AIGC = 'aigc', + // 可能会引人不适 + MaybeUnsuitable = 'maybe_unsuitable', + // 虚拟作品。仅供娱乐 + OnlyFunNew = 'only_fun_new', + // 危险行为,请勿模仿 + DangerousBehavior = 'dangerous_behavior', + // 内容自行拍摄 + SelfShoot = 'self_shoot', + // 内容取材网络 + FromNetV3 = 'from_net_v3', +} + +export type DiffParmasType = { + [AccountType.Xhs]?: object; + [AccountType.Douyin]?: { + // 申请关联的热点 + hotPoint?: ILableValue; + // 申请关联的活动 + activitys?: ILableValue[]; + // 自主声明 + selfDeclare?: DeclarationDouyin; + }; + [AccountType.WxSph]?: { + // 是否为原创 + isOriginal?: boolean; + // 扩展链接 + extLink?: string; + // 活动 + activity?: WxSphEvent; + }; + [AccountType.KWAI]?: object; +}; + +// 可见性 +export enum VisibleTypeEnum { + // 所有人可见 + Public = 1, + // 仅自己可见 + Private = 2, + // 好友可见 + Friend = 3, +} + +@Schema() +export class WorkData extends TimeTemp { + @Prop({ + required: true, + unique: true, + index: true, + type: Number, + }) + id: number; + + @Prop({ + required: false, + }) + dataId?: string; + + @Prop({ + required: true, + index: true, + type: String, + }) + userId: string; + + @Prop({ + required: false, + type: Date, + }) + lastStatsTime?: Date; // 最后统计时间 + + @Prop({ + required: false, + type: String, + }) + previewVideoLink: string; // 预览地址,这个值是发布完成手动拼接的 + + @Prop({ + required: true, + index: true, + type: Number, + }) + pubRecordId: number; // 发布记录id,对应PubRecord表id + + @Prop({ + required: true, + index: true, + type: Number, + }) + accountId: number; // 账号id + + @Prop({ + required: true, + enum: AccountType, + index: true, + }) + type: AccountType; // 平台类型 + + @Prop({ + required: false, + type: Date, + }) + publishTime?: Date; // 发布时间 + + @Prop({ + required: false, + type: Object, + }) + otherInfo?: Record; // 其他信息 + + @Prop({ + required: false, + }) + failMsg?: string; // 发布失败原因(如果失败) + + @Prop({ + required: true, + enum: PubStatus, + default: PubStatus.UNPUBLISH, + }) + status: PubStatus; + + @Prop({ + required: true, + default: 0, + }) + readCount: number; + + @Prop({ + required: true, + default: 0, + }) + likeCount: number; + + @Prop({ + required: true, + default: 0, + }) + collectCount: number; + + @Prop({ + required: true, + default: 0, + }) + forwardCount: number; + + @Prop({ + required: true, + default: 0, + }) + commentCount: number; + + @Prop({ + required: true, + default: 0, + }) + income: number; + + // 以下为发布需要的参数 -------------------------------------------------------------------- + @Prop({ + required: false, + }) + title?: string; // 标题 + + @Prop({ + required: false, + }) + desc?: string; // 简介,简介中不该包含话题,如果有需要每个平台再自己做处理。 + + @Prop({ + required: false, + }) + coverPath?: string; // 封面路径,机器的本地路径 + + @Prop({ + required: false, + type: Object, + }) + mixInfo?: ILableValue; // 合集 + + @Prop({ + required: true, + type: [String], + }) + topics: string[]; // 话题 格式:['话题1', '话题2'],不该包含 ‘#’ + + @Prop({ + required: false, + type: Object, + }) + location?: ILocationDataItem; // 位置 + + /** + * 差异化参数 + * 所有平台有通用参数,如:标题、话题、简介 + * 也有每个平台自己独有的参数,如:抖音活动奖励、抖音热点、视频号声明原创 + */ + @Prop({ + required: false, + type: Object, + }) + diffParams?: DiffParmasType; + + @Prop({ + required: true, + enum: VisibleTypeEnum, + default: VisibleTypeEnum.Private, + }) + visibleType?: VisibleTypeEnum; + + @Prop({ + required: false, + type: Date, + }) + timingTime?: Date; // 定时发布日期 + + @Prop({ + required: false, + type: Object, + }) + cookies?: Record; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_cert.pem b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_cert.pem new file mode 100644 index 000000000..d5eb220a1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_cert.pem @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +*************************************************************************** +-----END CERTIFICATE----- diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_key.pem b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_key.pem new file mode 100644 index 000000000..47bd97bc2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/files/wxcert/apiclient_key.pem @@ -0,0 +1,3 @@ +-----BEGIN PRIVATE KEY----- +*** +-----END PRIVATE KEY----- diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.back-code.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.back-code.ts new file mode 100644 index 000000000..bd305c9a0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.back-code.ts @@ -0,0 +1,248 @@ +/* + * @Author: niuwenzheng + * @Date: 2020-08-24 22:51:56 + * @LastEditors: nevin + * @LastEditTime: 2024-12-10 19:02:32 + * @Description: http业务错误返回map + */ +export interface ErrorHttpBack { + errCode: string; + message: string; +} + +export enum ErrHttpBack { + fail = 'fail', + err_err_token = 'err_err_token', + err_no_other_server = 'err_no_other_server', + // ======== 权限相关 ======== + err_no_permission = 'err_no_permission', + err_group = 'err_group', + err_no_assigne_menber = 'err_no_assigne_menber', + err_group_hasd = 'err_group_hasd', + + // ======= 用户相关 ======== + err_user_had = 'err_user_had', + err_user_no_had = 'err_user_no_had', + err_mail_code = 'err_mail_code', + err_no_power_login = 'err_no_power_login', + err_user_phone_repetition = 'err_user_phone_repetition', + err_user_code_had = 'err_user_code_had', + err_user_code_nohad = 'err_user_code_nohad', + err_user_code_send_fail = 'err_user_code_send_fail', + err_user_phone_null = 'err_user_phone_null', + err_user_pop_code_null = 'err_user_pop_code_null', + err_mail_send_fail = 'err_mail_send_fail', + + // ======= 认证相关 ======== + err_approve_need_only_one = 'err_approve_need_only_one', + err_approve_idcard_invalid = 'err_approve_idcard_invalid', + + // ======= 作品相关 ======== + err_works_no_had = 'err_works_no_had', + err_product_can_buy_one = 'err_product_can_buy_one', + err_stamp_insufficient = 'err_stamp_insufficient', + + // ======= 任务相关 ======== + user_task_no_had = 'user_task_no_had', + user_task_err_status = 'user_task_err_status', + // ======= 财务相关 ======== + wallet_account_no_had = 'wallet_account_no_had', + wallet_balance_no_enough = 'wallet_balance_no_enough', + task_no_material = 'task_no_material', +} + +export const ErrHttpBackMap: Map = new Map([ + [ErrHttpBack.fail, { errCode: '1', message: '请求失败' }], + // -------- 认证相关 ---------- + [ + ErrHttpBack.err_err_token, + { + errCode: '10010', + message: 'token验证失败', + }, + ], + [ + ErrHttpBack.err_no_other_server, + { + errCode: '10011', + message: '错误的服务请求', + }, + ], + // -------- 权限相关 ---------- + [ + ErrHttpBack.err_no_permission, + { + errCode: '20010', + message: '无操作权限', + }, + ], + [ + ErrHttpBack.err_group, + { + errCode: '20011', + message: '权限组有误', + }, + ], + [ + ErrHttpBack.err_no_assigne_menber, + { + errCode: '20012', + message: '不能没有指定人', + }, + ], + [ + ErrHttpBack.err_group_hasd, + { + errCode: '20013', + message: '该权限组已存在', + }, + ], + // -------- 用户相关 ---------- + [ + ErrHttpBack.err_no_power_login, + { + errCode: '40010', + message: '您无权登录', + }, + ], + [ + ErrHttpBack.err_user_had, + { + errCode: '40011', + message: '用户已存在', + }, + ], + [ + ErrHttpBack.err_user_no_had, + { + errCode: '40012', + message: '用户不存在', + }, + ], + [ + ErrHttpBack.err_user_phone_repetition, + { + errCode: '40013', + message: '用户手机号重复', + }, + ], + [ + ErrHttpBack.err_user_code_had, + { + errCode: '40019', + message: '验证码未过期', + }, + ], + [ + ErrHttpBack.err_user_code_send_fail, + { + errCode: '40020', + message: '验证码发送失败', + }, + ], + [ + ErrHttpBack.err_stamp_insufficient, + { + errCode: '40021', + message: '用户余额不足', + }, + ], + [ + ErrHttpBack.err_user_code_nohad, + { + errCode: '40022', + message: '验证码验证失败', + }, + ], + [ + ErrHttpBack.err_user_phone_null, + { + errCode: '40023', + message: '用户手机号状态有误', + }, + ], + + [ + ErrHttpBack.err_user_pop_code_null, + { + errCode: '40024', + message: '填写的邀请码有误', + }, + ], + + [ + ErrHttpBack.err_mail_send_fail, + { + errCode: '40025', + message: '邮件发送失败', + }, + ], + + // -------- 商品相关 ---------- + [ + ErrHttpBack.err_works_no_had, + { + errCode: '50010', + message: '作品不存在', + }, + ], + [ + ErrHttpBack.err_product_can_buy_one, + { + errCode: '50011', + message: '商品只能购买一个', + }, + ], + [ + ErrHttpBack.user_task_no_had, + { + errCode: '60010', + message: '用户任务不存在', + }, + ], + [ + ErrHttpBack.user_task_err_status, + { + errCode: '60011', + message: '用户任务状态错误', + }, + ], + // -------- 成员相关 ---------- + [ + ErrHttpBack.err_approve_need_only_one, + { + errCode: '70010', + message: '认证类型只能有一个', + }, + ], + [ + ErrHttpBack.err_approve_idcard_invalid, + { + errCode: '70011', + message: '身份证验证失败', + }, + ], + // -------- 财务相关 ---------- + [ + ErrHttpBack.wallet_account_no_had, + { + errCode: '80001', + message: '用户钱包账户不存在', + }, + ], + [ + ErrHttpBack.wallet_balance_no_enough, + { + errCode: '80002', + message: '用户余额不足', + }, + ], + // -------- 素材 ---------- + [ + ErrHttpBack.task_no_material, + { + errCode: '90000', + message: '该任务没有添加素材', + }, + ], +]); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.filter.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.filter.ts new file mode 100644 index 000000000..8d51c2bd7 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/filters/http-exception.filter.ts @@ -0,0 +1,170 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 16:05:23 + * @LastEditors: nevin + * @LastEditTime: 2025-04-27 14:35:40 + * @Description: 全局错误处理 + */ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + UnauthorizedException, + HttpStatus, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { HttpResult } from '../global/interface/response.interface'; + +import { ErrHttpBackMap } from './http-exception.back-code'; + +const BASE_ERROR_CODE = '1'; +const ARG_ERROR_CODE = '-1'; // 参数错误码 + +interface ExceptionResponseObj { + message?: ''; + statusCode?: ''; + error?: ''; +} + +// 全部错误 +@Catch(Error) +export class AppExceptionFilter implements ExceptionFilter { + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + console.log('---- Error ---', error); + + Logger.error({ + url: request.originalUrl, + level: 'error', + message: request.originalUrl, + mate: error.stack, + stack: error.stack, + }); + + const errorResponse: HttpResult = { + data: '', + msg: '', + code: BASE_ERROR_CODE, // 自定义code + url: request.originalUrl, // 错误的url地址 + }; + + response.status(HttpStatus.INTERNAL_SERVER_ERROR); + response.header('Content-Type', 'application/json; charset=utf-8'); + response.send(errorResponse); + } +} + +@Catch(BadRequestException) +export class BadExceptionFilter implements ExceptionFilter { + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + Logger.error({ + url: request.originalUrl, + level: 'error', + message: request.originalUrl, + mate: error.stack, + stack: error.stack, + }); + + const errorResponse: HttpResult = { + data: '', + msg: error.message, + code: ARG_ERROR_CODE, // 自定义code + url: request.originalUrl, // 错误的url地址 + }; + + response.status(HttpStatus.INTERNAL_SERVER_ERROR); + response.header('Content-Type', 'application/json; charset=utf-8'); + response.send(errorResponse); + } +} + +// 认证错误 +@Catch(UnauthorizedException) +export class AuthExceptionFilter implements ExceptionFilter { + catch(error: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + Logger.error({ + url: request.originalUrl, + level: 'error', + message: request.originalUrl, + mate: error.stack, + stack: error.stack, + }); + + const errorResponse: HttpResult = { + data: '', + msg: '', + code: BASE_ERROR_CODE, // 自定义code + url: request.originalUrl, // 错误的url地址 + }; + + response.status(HttpStatus.UNAUTHORIZED); + response.header('Content-Type', 'application/json; charset=utf-8'); + response.send(errorResponse); + } +} + +// 业务报错 +export class AppHttpException extends HttpException { + constructor(errKey: string) { + super(errKey, HttpStatus.OK); + } +} + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + // 定义错误的返回对象 + const errorResponse: HttpResult = { + data: exception.getResponse(), + msg: '', + code: BASE_ERROR_CODE, // 自定义code + url: request.originalUrl, // 错误的url地址 + }; + + const defErrHttpBack = ErrHttpBackMap.get('fail'); + const errObj = exception.getResponse(); // 获取的错误返回对象 + + if (typeof errObj === 'object') { + errorResponse.msg = + (errObj).message || defErrHttpBack.message; + errorResponse.code = + (errObj).error || defErrHttpBack.errCode; + errorResponse.data = errObj; + } + + if (typeof errObj === 'string') { + const errBackObj = + ErrHttpBackMap.get(exception.message) || defErrHttpBack; + + errorResponse.code = errBackObj.errCode; + errorResponse.msg = errBackObj.message || errObj; + errorResponse.data = ''; + } + + Logger.log(errorResponse.code + ':' + errorResponse.msg); + + // 设置返回的状态码、请求头、发送错误信息 + response.status( + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR, + ); + response.header('Content-Type', 'application/json; charset=utf-8'); + response.send(errorResponse); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/correctResponse.class.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/correctResponse.class.ts new file mode 100644 index 000000000..9efcc0a41 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/correctResponse.class.ts @@ -0,0 +1,16 @@ +import { CorrectResponse } from '../interface/table.interface'; +export class ResponseUtil { + static GetCorrectResponse( + pageNo: number, + pageSize: number, + count: number, + list: T[], + ): CorrectResponse { + return { + pageNo, + pageSize, + count, + list, + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/tableUtli.class.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/tableUtli.class.ts new file mode 100644 index 000000000..966b9619f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/class/tableUtli.class.ts @@ -0,0 +1,20 @@ +/* + * @Author: nevin + * @Date: 2024-10-09 17:08:55 + * @LastEditTime: 2024-10-10 15:46:56 + * @LastEditors: nevin + * @Description: + */ +import { TableDto } from '../dto/table.dto'; + +export class TableUtil { + // 获取分页信息 + static GetSqlPaging(paging: TableDto) { + if (!paging) return {}; + + return { + skip: (paging.pageNo - 1) * paging.pageSize || 0, + take: paging.pageSize, + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/dto/table.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/dto/table.dto.ts new file mode 100644 index 000000000..812265647 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/dto/table.dto.ts @@ -0,0 +1,41 @@ +/* + * @Author: nevin + * @Date: 2022-03-17 18:14:52 + * @LastEditors: nevin + * @LastEditTime: 2024-10-10 15:45:59 + * @Description: 表单数据 + */ + +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsInt, IsOptional } from 'class-validator'; + +export class TableDto { + @Type(() => Number) + @IsInt({ message: '页码必须是数值' }) + @IsOptional() + @Expose() + readonly pageNo?: number = 1; + + @Type(() => Number) + @IsInt({ message: '每页个数必须是数值' }) + @Expose() + @IsOptional() + readonly pageSize?: number = 10; + + @Type(() => Number) + @IsInt({ message: '分页标识必须是数值' }) + @IsOptional() + readonly paging?: number = 1; +} + +export class TableResDto { + @ApiProperty({ title: '页码', description: '页码' }) + readonly pageNo: number = 1; + + @ApiProperty({ title: '页数', description: '页数' }) + readonly pageSize: number = 10; + + @ApiProperty({ title: '总数', description: '总数' }) + readonly count: number = 0; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/all.enum.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/all.enum.ts new file mode 100644 index 000000000..2782e776d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/all.enum.ts @@ -0,0 +1,29 @@ +/* + * @Author: nevin + * @Date: 2022-08-02 16:18:04 + * @LastEditTime: 2025-01-15 14:37:02 + * @LastEditors: nevin + * @Description: + */ +export enum HttpTags { + DOC = 'doc', +} + +// 性别 +export enum GenderEnum { + MALE = 1, // 男 + FEMALE = 2, // 女 +} + +// 开关 +export enum ONOFF { + ON = 1, // 开 + OFF = 0, // 关 +} + +// 0 待审核 1 审核通过 -1 审核不通过 +export enum CheckStatus { + PENDING = 0, + PASS = 1, + FAIL = -1, +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/area.enum.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/area.enum.ts new file mode 100644 index 000000000..c3edd45c8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/enum/area.enum.ts @@ -0,0 +1,12 @@ +/* + * @Author: nevin + * @Date: 2022-05-11 14:44:41 + * @LastEditTime: 2024-06-17 19:28:45 + * @LastEditors: nevin + * @Description: + */ +export enum AreaTypes { + Province = 1, + City = 2, + County = 3, +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/response.interface.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/response.interface.ts new file mode 100644 index 000000000..0294925d5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/response.interface.ts @@ -0,0 +1,14 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 16:41:36 + * @LastEditors: nevin + * @LastEditTime: 2024-06-17 19:37:02 + * @Description: 请求返回接口 + */ + +export interface HttpResult { + data: T; // 数据 + msg: string; // 信息 + code: 0 | string; // 自定义code + url: string; // 错误的url地址 +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/table.interface.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/table.interface.ts new file mode 100644 index 000000000..4464d6349 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/global/interface/table.interface.ts @@ -0,0 +1,13 @@ +/* + * @Author: nevin + * @Date: 2022-03-17 16:05:38 + * @LastEditors: nevin + * @LastEditTime: 2025-03-03 18:59:47 + * @Description: 表格状数据 + */ +export interface CorrectResponse { + list: T[]; + pageSize: number; + pageNo: number; + count: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/guard/original.guard.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/guard/original.guard.ts new file mode 100644 index 000000000..ae3c3e2f2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/guard/original.guard.ts @@ -0,0 +1,31 @@ +/* + * @Author: nevin + * @Date: 2024-12-22 21:14:15 + * @LastEditTime: 2025-02-25 22:13:11 + * @LastEditors: nevin + * @Description: 保留原始请求守卫 + */ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { SetMetadata } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; + +export const IS_ORIGINAL_KEY = 'isOriginal'; +export const Original = () => SetMetadata(IS_ORIGINAL_KEY, true); + +@Injectable() +export class OriginalGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + async canActivate(context: ExecutionContext): Promise { + const isPass = this.reflector.getAllAndOverride(IS_ORIGINAL_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPass) { + // 💡 查看此条件 + return true; + } + + return true; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/logging.interceptor.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/logging.interceptor.ts new file mode 100644 index 000000000..1e622bc35 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/logging.interceptor.ts @@ -0,0 +1,33 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private logger = new Logger(LoggingInterceptor.name, { timestamp: false }); + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable { + const call$ = next.handle(); + const request = context.switchToHttp().getRequest(); + const content = `${request.method} -> ${request.url}`; + const isSse = request.headers.accept === 'text/event-stream'; + this.logger.debug(`+++ 请求:${content}`); + const now = Date.now(); + + return call$.pipe( + tap(() => { + if (isSse) return; + + this.logger.debug(`--- 响应:${content}${` +${Date.now() - now}ms`}`); + }), + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/transform.interceptor.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/transform.interceptor.ts new file mode 100644 index 000000000..c1fbf2601 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/interceptor/transform.interceptor.ts @@ -0,0 +1,73 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 15:56:08 + * @LastEditors: nevin + * @LastEditTime: 2025-02-25 00:28:07 + * @Description: 全局拦截器 慢日志打印 + */ +import { + Injectable, + NestInterceptor, + CallHandler, + ExecutionContext, + Logger, + CanActivate, +} from '@nestjs/common'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; +import { HttpResult } from '../global/interface/response.interface'; + +@Injectable() +export class OrgGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + request.is_org = true; + return true; + } +} + +@Injectable() +export class TransformInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const startTime = Date.parse(new Date().toString()); + + const ctx = context.switchToHttp(); + // const response: Response = ctx.getResponse(); + const request = ctx.getRequest(); + const reqUrl = request.originalUrl; + + return next.handle().pipe( + map((data: T): HttpResult | any => { + // --------- 慢日志打印警告 STR --------- + const ruqTime = Date.parse(new Date().toString()) - startTime; + if (ruqTime >= 50) { + Logger.verbose({ + level: 'verbose', + message: `${reqUrl}::${ruqTime}ms`, + mate: ruqTime, + }); + } + // --------- 慢日志打印警告 END --------- + + // 不进行封装的返回 + if (request.is_org) return data as any; + + if ((data as unknown as number) !== 0 && !data && data !== false) + return data; + + // 封装 + return { + data, + code: 0, + msg: '请求成功', + url: reqUrl, + }; + }), + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.module.ts new file mode 100644 index 000000000..1cd68d5d3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.module.ts @@ -0,0 +1,31 @@ +/* + * @Author: nevin + * @Date: 2022-01-20 09:20:31 + * @LastEditors: nevin + * @LastEditTime: 2024-07-05 15:48:57 + * @Description: 邮件模块 + */ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import mailConfig from 'config/mail.config'; +import { MailService } from './mail.service'; +import { MailerModule } from '@nestjs-modules/mailer'; + +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [mailConfig], // 加载自定义配置项 + }), + + MailerModule.forRootAsync({ + imports: [ConfigModule], // 数据库配置项依赖于ConfigModule,需在此引入 + inject: [ConfigService], + useFactory: (configService: ConfigService) => + configService.get('MAIL_CONFIG'), + }), + ], + providers: [MailService], + exports: [MailService], +}) +export class MailModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.service.ts new file mode 100644 index 000000000..d1f5ba056 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/mail/mail.service.ts @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2024-06-11 10:27:06 + * @LastEditTime: 2024-07-05 15:50:12 + * @LastEditors: nevin + * @Description: + */ +import { ISendMailOptions, MailerService } from '@nestjs-modules/mailer'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class MailService { + constructor(private readonly mailerService: MailerService) {} + + async sendEmail(p: ISendMailOptions): Promise { + try { + const res = await this.mailerService.sendMail(p); + return !!res; + } catch (error) { + console.log('------- sendEmail error --------', error); + return false; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss-core.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss-core.module.ts new file mode 100644 index 000000000..eaee94b10 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss-core.module.ts @@ -0,0 +1,35 @@ +/* + * @Author: nevin + * @Date: 2022-03-03 16:50:53 + * @LastEditors: nevin + * @LastEditTime: 2024-06-24 17:49:30 + * @Description: 阿里云OSS文件存储 + */ +import { DynamicModule, Global, Module } from '@nestjs/common'; +import OSS from 'ali-oss'; +import { OssProvider } from './oss.provider'; +import { OssController } from './oss.controller'; + +@Global() +@Module({ + providers: [OssProvider], + controllers: [OssController], +}) +export class OssCoreModule { + /** + * 注册OSS服务 + * @param options + * @param resetName 重命名 + */ + static forRoot(options: OSS.Options, resetName?: string): DynamicModule { + const ossClientProvider = OssProvider.createClientProvider( + options, + resetName, + ); + return { + module: OssCoreModule, + providers: [ossClientProvider], + exports: [ossClientProvider], + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.controller.ts new file mode 100644 index 000000000..56e7b9c85 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.controller.ts @@ -0,0 +1,89 @@ +/* + * @Author: nevin + * @Date: 2022-03-07 13:37:06 + * @LastEditors: nevin + * @LastEditTime: 2024-12-22 21:58:14 + * @Description: 文件上传 + */ +import { + Controller, + Post, + UploadedFile, + UploadedFiles, + UseInterceptors, + Headers, + Body, +} from '@nestjs/common'; +import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; +import { OssService } from './oss.service'; +import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../auth/auth.guard'; + +@ApiTags('OSS - 文件(阿里云)') +@Controller('oss') +export class OssController { + constructor(private readonly ossService: OssService) {} + + @ApiOperation({ description: '存入临时目录', summary: '上传文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @Public() + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async uploadFile( + @UploadedFile() file: Express.Multer.File, + @Headers() headers: any, + ) { + const secondPath: string = headers['second-path']; + return await this.ossService.upFileStream(file, secondPath); + } + + @ApiOperation({ description: '不存入临时目录', summary: '上传文件' }) + @Public() + @Post('upload/permanent') + @UseInterceptors(FileInterceptor('file')) + async uploadPermanentFile( + @UploadedFile() file: Express.Multer.File, + @Headers() headers: any, + ) { + const secondPath: string = headers['second-path']; + + // 不开启临时目录 + return await this.ossService.upFileStream(file, secondPath, null, true); + } + + @ApiOperation({ description: '存入临时目录', summary: '上传文件数组' }) + @Public() + @Post('upload/list') + @UseInterceptors(FilesInterceptor('files')) + uploadFileList( + @UploadedFiles() files: Express.Multer.File[], + @Headers() headers: any, + ) { + const secondPath: string = headers['second-path']; + files.forEach((file) => { + this.ossService.upFileStream(file, secondPath); + }); + return { status: 'ok' }; + } + + @ApiOperation({ description: '上传URL图片', summary: '上传URL图片' }) + @Public() + @Post('upload/url') + async uploadFileOfUrl(@Body() body: { path: string; url: string }) { + const res = await this.ossService.upFileByUrl(body.url, { + path: body.path, + }); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.interface.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.interface.ts new file mode 100644 index 000000000..19725c106 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.interface.ts @@ -0,0 +1,23 @@ +/* + * @Author: nevin + * @Date: 2022-03-04 10:40:22 + * @LastEditors: nevin + * @LastEditTime: 2024-12-20 22:45:03 + * @Description: 阿里云OSS + */ +import OSS from 'ali-oss'; + +export type OssOptions = OSS.Options; + +export interface OssModuleAsyncOption { + imports?: any; + useValue?: OssOptions; + useFactory?: (...args: any[]) => OssOptions; // 生成options的构造函数 + inject?: any[]; // 注入 +} + +export interface OssNewFilePath { + path?: string; + permanent?: boolean; + newName?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.module.ts new file mode 100644 index 000000000..2cd854875 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.module.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2022-03-03 16:50:53 + * @LastEditors: nevin + * @LastEditTime: 2024-06-24 17:48:23 + * @Description: 阿里云OSS文件存储 + */ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import ossConfig from '../../../config/oss.config'; +import { OssCoreModule } from './oss-core.module'; +import { OssService } from './oss.service'; +import { OssController } from './oss.controller'; + +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [ossConfig], // 加载自定义配置项 + }), + OssCoreModule.forRoot(ossConfig().OSS_CONFIG.INIT_OPTION), + ], + controllers: [OssController], + providers: [OssService], + exports: [OssService], +}) +export class OssModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.provider.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.provider.ts new file mode 100644 index 000000000..abf5ad400 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.provider.ts @@ -0,0 +1,36 @@ +/* + * @Author: nevin + * @Date: 2022-02-18 09:38:19 + * @LastEditors: nevin + * @LastEditTime: 2024-07-18 14:09:07 + * @Description: 文件描述 + */ +import * as OSS from 'ali-oss'; +import { Provider } from '@nestjs/common'; + +export class OssProvider { + /** + * 创建连接 + * @param options + */ + private static createClient(options: OSS.Options): OSS { + return new OSS(options); + } + + /** + * 得到 oss 客户端连接的 provider + * @return {Provider} + */ + public static createClientProvider( + options: OSS.Options, + resetName?: string, + ): Provider { + return { + provide: resetName || 'OSS_CLIENT_PROVIDER', + useFactory: () => { + return this.createClient(options); + }, + // inject: ['OSS_CLIENT_PROVIDER'], + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.service.ts new file mode 100644 index 000000000..827d26522 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/oss/oss.service.ts @@ -0,0 +1,229 @@ +/* + * @Author: nevin + * @Date: 2022-03-03 16:59:23 + * @LastEditors: nevin + * @LastEditTime: 2025-02-25 23:11:01 + * @Description: oss函数 + */ +import { Injectable, Inject, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import OSS from 'ali-oss'; +import * as moment from 'moment'; +import { Duplex } from 'stream'; +import { v4 as uuidv4 } from 'uuid'; +import { OssNewFilePath } from './oss.interface'; +import axios from 'axios'; +@Injectable() +export class OssService { + private HOST_URL: string; + private NODE_ENV: string; + constructor( + @Inject('OSS_CLIENT_PROVIDER') private client: OSS, + private configService: ConfigService, + ) { + this.HOST_URL = this.configService.get('OSS_CONFIG.HOST_URL'); + this.NODE_ENV = this.configService.get('SERVER_CONFIG.NODE_ENV'); + } + + private getNewFilePath(opt: OssNewFilePath) { + let { path, newName } = opt; + + path = `${this.NODE_ENV}/${opt.permanent ? '' : 'temp/'}${path || `nopath/${moment().format('YYYYMM')}`}`; + path = path.replace('//', '/'); + newName = newName || uuidv4(); + + return { + path, + newName, + }; + } + + /** + * 获取完整url中的文件名即去除host域名 + * @param url + * @returns + */ + getFileNameFromUrl(url: string) { + const tempStr = url.split(`${this.HOST_URL}/`); + return tempStr.length <= 0 ? '' : tempStr[tempStr.length - 1]; + } + + /** + * 文件上传 + * @param {Express.Multer.File} file 文件buffer流对象 + * @param {string | undefined} path 路径,不传就会使用‘nopath’前缀 + * @param {string | undefined} newName 新的文件名 + * @param {string | undefined} permanent 是否为永久目录,默认临时 + * @returns + */ + async upFileStream( + file: Express.Multer.File, + path?: string, + newName?: string, + permanent?: boolean, + ) { + const { buffer, mimetype } = file; + const ret = this.getNewFilePath({ path, newName, permanent }); + path = ret.path; + newName = ret.newName; + + const tempStr = mimetype.split('/'); + const fileTypeStr = tempStr[tempStr.length - 1]; + + const stream = new Duplex(); + stream.push(buffer); + stream.push(null); + + try { + const upRes = await this.client.putStream( + `${path}/${newName}.${fileTypeStr}`, + stream, + ); + + const { + name, + res: { status }, + } = upRes; + + if (status !== HttpStatus.OK) throw new Error('文件上传失败'); + + return { + name, + }; + } catch (error) { + throw error; + } + } + + /** + * 去除文件前置 + * @param filePath + */ + private noHostFilePath(filePath: string) { + const hostUrl = this.configService.get('OSS_CONFIG.HOST_URL'); + + const _hostUrl = hostUrl.replace('https', 'http'); + if (filePath.indexOf(_hostUrl) === 0) { + filePath = filePath.replace(`${_hostUrl}/`, ''); + return this.noHostFilePath(filePath); + } + + return filePath; + } + + /** + * 将临时目录文件转到新的目录文件 + * @param filePath 原文件地址 + * @param newFilePath 不填就是去除temp标识 + * @returns + */ + async changeFilePath( + filePath: string, + newFilePath?: string, + ): Promise { + // 干掉前置 + filePath = this.noHostFilePath(filePath); + + // 复制文件 + newFilePath = newFilePath || filePath.replace('temp/', ''); + + if (filePath === newFilePath) return filePath; + + try { + const { + res: { status }, + } = await this.client.copy(newFilePath, filePath); + if (status !== HttpStatus.OK) throw new Error('文件上传失败'); + return newFilePath; + } catch (error) { + console.log('========== error', error); + throw error; + } + } + + /** + * 上传二进制流文件 + * @param buffer 二进制流 base64格式 + * @param path 路径 + * @param permanent 是否为永久目录,默认临时 + */ + async uploadByStream( + buffer: Buffer, // base64格式(不带前缀) + option: { + path?: string; + permanent?: boolean; + fileType: string; + }, + ): Promise { + const { path, permanent, fileType } = option; + const objectName = `${this.NODE_ENV}/${permanent ? '' : 'temp/'}${path || 'nopath'}${`/${moment().format('YYYYMM')}/${uuidv4()}.${fileType}`}`; + + // 上传到阿里云 + try { + const res = await this.client.put(objectName, buffer); + return res.name; + } catch (error) { + console.log('----- uploadByBase64 error -----', error); + return ''; + } + } + + /** + * 根据网络地址上传到阿里云 + * @param url + * @param option + * @returns + */ + async upFileByUrl( + url: string, + option: { + path?: string; + permanent?: boolean; + }, + ) { + const { path, permanent } = option; + let fileType = ''; + if (url.includes('.')) { + fileType = url.split('.').pop(); + } + const objectName = `${this.NODE_ENV}/${permanent ? '' : 'temp/'}${path || 'nopath'}${`/${moment().format('YYYYMM')}/${uuidv4()}.${fileType}`}`; + + try { + const response = await axios.get(url, { responseType: 'arraybuffer' }); + const buffer = Buffer.from(response.data); + + const res = await this.client.put(objectName, buffer); + return res.name; + } catch (error) { + console.log('----- upFileByUrl error -----', error); + return ''; + } + } + + /** + * TODO: 未完成 未使用 上传base64图片 + */ + async uploadByBuffer( + base64Img: string, + path?: string, + permanent?: boolean, + ): Promise { + const imageBuffer = Buffer.from(base64Img, 'base64'); + const objectName = `${this.NODE_ENV}/${permanent ? '' : 'temp/'}${`nopath/${moment().format('YYYYMM')}/${uuidv4()}.png`}`; + try { + const { + url, + res: { status }, + } = await this.client.put(objectName, imageBuffer); + + if (status === 200) { + return url; + } else { + return ''; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (error) { + return ''; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/comment.ts new file mode 100644 index 000000000..736c3126d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/comment.ts @@ -0,0 +1,2 @@ +export const BaseUrl = 'http://plat-auth.yikart.cn'; +// export const BaseUrl = 'http://127.0.0.1:3333'; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/platAuth.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/platAuth.module.ts new file mode 100644 index 000000000..c34116fd1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/platAuth.module.ts @@ -0,0 +1,22 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:27 + * @LastEditTime: 2025-02-25 09:47:37 + * @LastEditors: nevin + * @Description: platAuth PlatAuth + */ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import wxConfig from '../../../config/wx.config'; +import { PlatAuthWxGzhService } from './wxGzh.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [wxConfig], // 加载自定义配置项 + }), + ], + providers: [PlatAuthWxGzhService], + exports: [PlatAuthWxGzhService], +}) +export class PlatAuthModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/wxGzh.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/wxGzh.service.ts new file mode 100644 index 000000000..a4c4ef853 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/platAuth/wxGzh.service.ts @@ -0,0 +1,110 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-04-14 17:10:59 + * @LastEditors: nevin + * @Description: 艺咖三方平台认证服务 PlatAuth platAuth + */ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { BaseUrl } from './comment'; +import * as crypto from 'crypto'; + +@Injectable() +export class PlatAuthWxGzhService { + appId = ''; + secret = ''; + vi = 'yika2025'; // 盐 + constructor(private readonly configService: ConfigService) { + this.appId = this.configService.get('WX_GZH.WX_GZH_ID'); + this.secret = this.configService.get('WX_GZH.WX_GZH_SECRET'); + } + + // 生成加密 + private async generateEncryptionKey(): Promise { + // 1.appId+secret进行sha1加密 + const encryptionKey = crypto + .createHash('sha256') + .update(this.appId + this.secret) + .digest(); + + const vi = Buffer.from(this.vi, 'utf8') + .toString() + .padEnd(16, '0') + .slice(0, 16); // 强制16字节 + + // 加入当前时间戳,进行AES加密 + // 3. 时间戳 AES 加密 + const timestamp = Date.now().toString(); // 显式转为字符串 + const cipher = crypto.createCipheriv('aes-256-cbc', encryptionKey, vi); + let encrypted = cipher.update(timestamp, 'utf8', 'hex'); + encrypted += cipher.final('hex'); // 合并 update 和 final 的结果 + return encrypted; + } + + /** + * 获取微信登录二维码的票据 + * @returns + */ + async getWxLoginQrcode(): Promise<{ + key: string; + ticket: string; + }> { + const url = `${BaseUrl}/wxGzh/qrcode/get/${this.appId}`; + const result = await axios.get<{ + code: number; + message: string; + data: { + key: string; + ticket: string; + }; + }>(url); + + return result.data.data; + } + + /** + * 创建公众号菜单 + */ + async createWxGzhMenu(body: any): Promise<{ + errcode: number; + errmsg: string; + }> { + if (typeof body !== 'object' || body === null || Array.isArray(body)) + return { errcode: 400, errmsg: '菜单必须为非空对象' }; + + const sign = await this.generateEncryptionKey(); + console.log('----- sign: ', sign); + + const url = `${BaseUrl}/wxGzh/menu/create/${this.appId}`; + const result = await axios.post<{ + code: number; + message: string; + data: { + errcode: number; + errmsg: string; + }; + }>(url, { data: body, authKey: sign }); + + return result.data.data; + } + + /** + * 获取菜单 + */ + async getMenu(): Promise<{ + errcode: number; + errmsg: string; + data: any; + }> { + const url = `${BaseUrl}/wxGzh/menu/get/${this.appId}`; + const result = await axios.get<{ + code: number; + message: string; + data: any; + }>(url); + + return result.data.data; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.module.ts new file mode 100644 index 000000000..9a402d6c8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.module.ts @@ -0,0 +1,22 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:27 + * @LastEditTime: 2025-02-25 09:47:37 + * @LastEditors: nevin + * @Description: realAuth RealAuth + */ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import realAuth from '../../../config/realAuth.config'; +import { AlicloudRealAuthService } from './realAuth.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [realAuth], // 加载自定义配置项 + }), + ], + providers: [AlicloudRealAuthService], + exports: [AlicloudRealAuthService], +}) +export class RealAuthModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.service.ts new file mode 100644 index 000000000..47c510c95 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/realAuth/realAuth.service.ts @@ -0,0 +1,68 @@ +/* + * @Author: nevin + * @Date: 2025-04-27 06:41:31 + * @LastEditTime: 2025-04-27 14:03:43 + * @LastEditors: nevin + * @Description: + */ +import Cloudauth20190307, * as $Cloudauth20190307 from '@alicloud/cloudauth20190307'; +import * as $OpenApi from '@alicloud/openapi-client'; +import * as $Util from '@alicloud/tea-util'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AlicloudRealAuthService { + private client: any; + constructor(private configService: ConfigService) { + const options: { + accessKeyId: string; + accessKeySecret: string; + } = this.configService.get('REAL_NAME_CONFIG'); + + const config = new $OpenApi.Config({ + ...options, + }); + + config.endpoint = `cloudauth.cn-beijing.aliyuncs.com`; + this.client = new Cloudauth20190307(config); + } + + /** + * 身份号认证 + * @param identifyNum + * @param userName + */ + public async realNameAuth( + identifyNum: string, + userName: string, + ): Promise { + const id2MetaVerifyRequest = new $Cloudauth20190307.Id2MetaVerifyRequest({ + paramType: 'normal', + identifyNum, + userName, + }); + const runtime = new $Util.RuntimeOptions({}); + try { + const res: { + body: { + code: string; + message: string; + requestId: string; + resultObject: { + bizCode: '1' | '2' | '3'; + }; + }; + } = await this.client.id2MetaVerifyWithOptions( + id2MetaVerifyRequest, + runtime, + ); + + return res.body.resultObject.bizCode === '1'; + } catch (error) { + console.log('----- realNameAuth error ------', error.message); + + return false; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.constant.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.constant.ts new file mode 100644 index 000000000..7c34e5171 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.constant.ts @@ -0,0 +1,8 @@ +/* + * @Author: nevin + * @Date: 2022-10-29 22:19:30 + * @LastEditTime: 2024-08-31 18:55:18 + * @LastEditors: nevin + * @Description: + */ +export const REDIS_CLIENT = 'REDIS_CLIENT'; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.module.ts new file mode 100644 index 000000000..45261920c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.module.ts @@ -0,0 +1,40 @@ +/* + * @Author: nevin + * @Date: 2024-08-30 14:39:05 + * @LastEditTime: 2024-09-14 19:03:21 + * @LastEditors: nevin + * @Description: + */ +import { Global, Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { RedisService } from './redis.service'; +import { REDIS_CLIENT } from './redis.constant'; +import Redis from 'ioredis'; +import redisConfig from '../../../config/redis.config'; + +@Global() +@Module({ + imports: [ConfigModule.forRoot({ load: [redisConfig] })], + providers: [ + RedisService, + { + provide: REDIS_CLIENT, + useFactory: async (configService: ConfigService) => { + const client = new Redis(configService.get('REDIS_CONFIG')); + console.log('Redis client connected.'); + client.on('error', (err) => { + console.error('Redis client encountered an error:', err); + }); + + client.on('end', () => { + console.log('Connection to Redis closed.'); + }); + + return client; + }, + inject: [ConfigService], + }, + ], + exports: [REDIS_CLIENT, RedisService], +}) +export class RedisModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.service.ts new file mode 100644 index 000000000..fd6a53ba6 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/redis/redis.service.ts @@ -0,0 +1,103 @@ +/* + * @Author: nevin + * @Date: 2024-08-31 19:15:15 + * @LastEditTime: 2024-09-18 11:42:41 + * @LastEditors: nevin + * @Description: + */ +import { Injectable, Inject } from '@nestjs/common'; +import { Redis } from 'ioredis'; +import { REDIS_CLIENT } from './redis.constant'; + +@Injectable() +export class RedisService { + constructor(@Inject(REDIS_CLIENT) private readonly client: Redis) {} + + /** + * 设置key-value + * @param key + * @param value + * @param seconds + * @returns + */ + async setKey(key: string, value: any, seconds?: number): Promise { + value = JSON.stringify(value); + if (!seconds) return !!(await this.client.set(key, value)); + + return !!(await this.client.set(key, value, 'EX', seconds)); + } + + /** + * 获取值 + * @param key + * @returns + */ + async get(key: string, isObj = true): Promise { + const data = await this.client.get(key); + if (!data) return null; + return isObj ? JSON.parse(data) : data; + } + + /** + * 清除值 + * @param key + * @returns + */ + async del(key: string): Promise { + const data = await this.client.del(key); + return !!data; + } + + /** + * 设置过期时间 + * @param key + * @param times + * @returns + */ + async setPexire(key: string, times = 0): Promise { + const data = await this.client.pexpire(key, times); + return data === 1; + } + + /** + * 生成订单号 + * @param h 前置标识 + */ + async generateOrderNumber(h: string = '') { + try { + // 获取今天的日期,格式化为 YYYYMMDD + const today = new Date(); + const datePrefix = `${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}`; + + // 使用 Redis 的 INCR 命令生成递增的序列号 + const sequence = await this.client.incr(`order_seq:${h}${datePrefix}`); + + // 格式化序列号,确保它有固定的位数,例如8位 + const formattedSequence = sequence.toString().padStart(8, '0'); + + // 拼接日期前缀和序列号,形成完整的订单号 + const orderNumber = `${h}${datePrefix}-${formattedSequence}`; + + return orderNumber; + } catch (error) { + console.error('Error generating order number:', error); + throw error; + } + } + + /** + * 重置订单号 + * @param h 前置标识 + */ + async resetOrderNumber(h: string = '') { + try { + const today = new Date(); + const datePrefix = `${today.getFullYear()}${today.getMonth() + 1}${today.getDate()}`; + + // 删除前一天的序列号键 + await this.client.del(`order_seq:${h}${datePrefix}`); + } catch (error) { + console.error('Error resetting order sequence:', error); + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-pns.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-pns.service.ts new file mode 100644 index 000000000..91df5a207 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-pns.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import * as $OpenApi from '@alicloud/openapi-client'; +import * as $Util from '@alicloud/tea-util'; +import { AlicloudSmsOptions } from './interfaces/alicloud-sms-options.interface'; +import { ConfigService } from '@nestjs/config'; +import Dypnsapi20170525, * as $Dypnsapi20170525 from '@alicloud/dypnsapi20170525'; + +@Injectable() +export class AlicloudPnsService { + private client: Dypnsapi20170525; + constructor(private configService: ConfigService) { + const options: AlicloudSmsOptions = this.configService.get('SMS_CONFIG'); + const config = new $OpenApi.Config({ + accessKeyId: options.config.accessKeyId, + accessKeySecret: options.config.accessKeySecret, + }); + + config.endpoint = `dypnsapi.aliyuncs.com`; + this.client = new Dypnsapi20170525(config); + } + + /** + * 获取一键登录的手机号 + * @param accessToken + * @param outId + * @returns + */ + async getOneKeyLoginPhone( + accessToken: string, + outId?: string, + ): Promise { + const runtime = new $Util.RuntimeOptions({}); + const getMobileRequest = new $Dypnsapi20170525.GetMobileRequest({ + accessToken, + outId, + }); + try { + const res = await this.client.getMobileWithOptions( + getMobileRequest, + runtime, + ); + + if (res.statusCode !== 200) throw new Error('not 200' + res.statusCode); + if (res.body.code !== 'OK') throw new Error(res.body.message); + + return res.body.getMobileResultDTO.mobile; + } catch (error) { + console.log('------ getOneKeyLoginPhone ---- error', error); + return ''; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.module.ts new file mode 100644 index 000000000..b9b5d7c2d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.module.ts @@ -0,0 +1,24 @@ +/* + * @Author: lish + * @Date: 2024-07-08 20:13:01 + * @LastEditors: lish + * @LastEditTime: 2024-07-08 21:06:37 + * @Description: Do not edit + */ +import { Module, Global } from '@nestjs/common'; +import { AlicloudSmsService } from './alicloud-sms.service'; +import { ConfigModule } from '@nestjs/config'; +import smsConfig from '../../../config/sms.config'; +import { AlicloudPnsService } from './alicloud-pns.service'; + +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [smsConfig], // 加载自定义配置项 + }), + ], + providers: [AlicloudSmsService, AlicloudPnsService], + exports: [AlicloudSmsService, AlicloudPnsService], +}) +export class AlicloudSmsModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.service.ts new file mode 100644 index 000000000..72d718b14 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/alicloud-sms.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import Dysmsapi20170525, * as $Dysmsapi20170525 from '@alicloud/dysmsapi20170525'; +import * as $OpenApi from '@alicloud/openapi-client'; +import * as $Util from '@alicloud/tea-util'; +import { AlicloudSmsOptions } from './interfaces/alicloud-sms-options.interface'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class AlicloudSmsService { + private client: Dysmsapi20170525; + private options: AlicloudSmsOptions; + constructor(private configService: ConfigService) { + const options: AlicloudSmsOptions = this.configService.get('SMS_CONFIG'); + this.options = options; + const config = new $OpenApi.Config({ + ...options.config, + }); + + this.client = new Dysmsapi20170525(config); + } + + /** + * Send message. + * @param phone 手机号 + * @param code 验证码 + */ + public async sendLoginSms(phone: string, code: string): Promise { + const sendSmsRequest = new $Dysmsapi20170525.SendSmsRequest({ + phoneNumbers: phone, + signName: this.options.defaults.signName, + templateCode: this.options.defaults.templateCode, + templateParam: JSON.stringify({ code }), + }); + + const runtime = new $Util.RuntimeOptions({}); + + try { + const result = await this.client.sendSmsWithOptions( + sendSmsRequest, + runtime, + ); + + if (result.body.code === 'isv.BUSINESS_LIMIT_CONTROL') { + console.log('发送短信失败,请稍后再试'); + return false; + } + + return true; + } catch (error: any) { + console.log('==== sendLoginSms ====', error); + return false; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/alicloud-sms-options.interface.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/alicloud-sms-options.interface.ts new file mode 100644 index 000000000..f4f439a78 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/alicloud-sms-options.interface.ts @@ -0,0 +1,100 @@ +/* + * @Author: lish + * @Date: 2024-07-08 20:13:01 + * @LastEditors: lish + * @LastEditTime: 2024-07-08 20:27:44 + * @Description: Do not edit + */ +export interface AlicloudSmsOptions { + config: { + /** + * Alicloud account accessKey ID. + * + * @see https://usercenter.console.aliyun.com/#/manage/ak + * @type {string} + */ + accessKeyId: string; + + /** + * Alicloud account accessKey secret. + * + * @see https://usercenter.console.aliyun.com/#/manage/ak + * @type {string} + */ + accessKeySecret: string; + + /** + * Alicloud API service url. + * + * @default 'https://dysmsapi.aliyuncs.com' + * @type {string} + */ + endpoint?: string; + + /** + * Alicloud SMS API version. + * + * @default 2017-05-25 + * @type {string} + */ + apiVersion?: string; + + opts?: { + /** + * @default 3000 + * @type {number} + */ + timeout?: number; + + /** + * Format the parameter name to first letter upper case + * + * @default true + * @type {boolean} + */ + formatParams?: boolean; + + /** + * Set the http method + * + * @default GET + * @type {('GET' | 'POST')} + */ + method?: 'GET' | 'POST'; + + /** + * Http request headers + * + * @type {object} + */ + headers?: object; + }; + }; + defaults?: { + /** + * SMS message template ID. + * + * @see 请在控制台模板管理页面模板CODE一列查看。 + * @type {string} + */ + signName?: string; + + /** + * Alicloud region ID. + * + * @type {string} + */ + regionId?: string; + + templateCode?: string; + }; + + /** + * Log sent message on dashboard. + * + * @default false + * @type {boolean} + * @memberof AlicloudSmsOptions + */ + logger?: boolean; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/index.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/index.ts new file mode 100644 index 000000000..caead4319 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/sms/interfaces/index.ts @@ -0,0 +1,8 @@ +/* + * @Author: lish + * @Date: 2024-07-08 20:13:01 + * @LastEditors: lish + * @LastEditTime: 2024-07-08 20:44:01 + * @Description: Do not edit + */ +export * from './alicloud-sms-options.interface'; diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.module.ts new file mode 100644 index 000000000..6bada9133 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.module.ts @@ -0,0 +1,23 @@ +/* + * @Author: lish + * @Date: 2024-07-08 20:13:01 + * @LastEditors: lish + * @LastEditTime: 2024-07-08 21:06:37 + * @Description: 内容安全模块 + */ +import { Module, Global } from '@nestjs/common'; +import { TmsService } from './tms.service'; +import { ConfigModule } from '@nestjs/config'; +import tmsConfig from 'config/tms.config'; + +@Global() +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [tmsConfig], + }), + ], + providers: [TmsService], + exports: [TmsService], +}) +export class TmsModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.service.ts new file mode 100644 index 000000000..2adee46cf --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/tms/tms.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as tencentcloud from 'tencentcloud-sdk-nodejs-tms'; +import { Client } from 'tencentcloud-sdk-nodejs-tms/tencentcloud/services/tms/v20201229/tms_client'; +const TmsClient = tencentcloud.tms.v20201229.Client; + +export enum TmsLabels { + Normal = 'Normal', + Porn = 'Porn', + Abuse = 'Abuse', + Ad = 'Ad', + Error = 'Error', +} + +@Injectable() +export class TmsService { + private client: Client; + constructor(private configService: ConfigService) { + const options: { secretId: string; secretKey: string } = + this.configService.get('TMS_CONFIG'); + + const clientConfig = { + credential: options, + + region: 'ap-beijing', + profile: { + httpProfile: { + endpoint: 'tms.tencentcloudapi.com', + }, + }, + }; + + this.client = new TmsClient(clientConfig); + } + + /** + * 内容安全验证 + * @param content 文本内容 + * **Normal**:正常,**Porn**:色情,**Abuse**:谩骂,**Ad**:广告;以及其他令人反感、不安全或不适宜的内容类型 **Error** + */ + public async textModeration(content: string): Promise { + try { + const base64 = Buffer.from(content).toString('base64'); + const result = await this.client.TextModeration({ + Content: base64, + }); + + return result.Label as TmsLabels; + } catch (error: any) { + console.log('==== textModeration ====', error); + return TmsLabels.Error; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/utils.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/utils.ts new file mode 100644 index 000000000..19c26c11c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/utils.ts @@ -0,0 +1,14 @@ +/* + * @Author: nevin + * @Date: 2025-01-20 16:36:41 + * @LastEditTime: 2025-02-26 14:41:25 + * @LastEditors: nevin + * @Description: + */ +import * as crypto from 'crypto'; + +export function generateSignature(sessionKey: string) { + const hmac = crypto.createHmac('sha256', sessionKey); + hmac.update(''); + return hmac.digest('hex'); +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.module.ts new file mode 100644 index 000000000..f1cf87bfe --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.module.ts @@ -0,0 +1,36 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:27 + * @LastEditTime: 2025-02-25 09:47:37 + * @LastEditors: nevin + * @Description: + */ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { WxService } from './wx.service'; +import wxConfig from '../../../config/wx.config'; +import { WxPayService } from './wxPay.service'; +import { WeChatPayModule } from 'nest-wechatpay-node-v3'; +import { WxGzhService } from './wxGzh.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [wxConfig], // 加载自定义配置项 + }), + WeChatPayModule.registerAsync({ + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + return { + appid: configService.get('WX_CONFIG.APP_ID'), + mchid: configService.get('WX_CONFIG.MCH_ID'), + publicKey: configService.get('WX_CONFIG.PUBLIC_KEY'), + privateKey: configService.get('WX_CONFIG.PRIVATE_KEY'), + }; + }, + }), + ], + providers: [WxService, WxPayService, WxGzhService], + exports: [WxService, WxPayService, WxGzhService], +}) +export class WxModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.service.ts new file mode 100644 index 000000000..0418b8aa5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wx.service.ts @@ -0,0 +1,122 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-02-24 22:20:10 + * @LastEditors: nevin + * @Description: 微信服务 + */ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { generateSignature } from './utils'; +import { RedisService } from '../redis/redis.service'; + +@Injectable() +export class WxService { + appId = ''; + appSecret = ''; + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) { + this.appId = this.configService.get('WX_CONFIG.APP_ID'); + this.appSecret = this.configService.get('WX_CONFIG.APP_SECRET'); + } + + /** + * 获取access_token + * @returns + */ + private async getMiniAppAccessToken(): Promise { + const orgValue: string = await this.redisService.get( + `${this.appId}:access_token`, + ); + if (orgValue) return orgValue; + + const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`; + const result: { + data: { + access_token: string; + expires_in: number; + }; + } = await axios.get(url); + + if (result.data.access_token) { + await this.redisService.setKey( + `${this.appId}:access_token`, + result.data.access_token, + result.data.expires_in, + ); + } + + return result.data.access_token; + } + + async code2Session(code: string): Promise<{ + session_key: string; + unionid: string; + errmsg: string; + openid: string; + errcode: number; + }> { + const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${this.appId}&secret=${this.appSecret}&js_code=${code}&grant_type=authorization_code`; + const result = await axios.get(url); + return result.data; + } + + /** + * 校验登录状态 + * @param openid + * @param session_key + * @returns + */ + async checkSessionKey(openid: string, session_key: string): Promise { + const access_token: string = await this.getMiniAppAccessToken(); + + const url = `https://api.weixin.qq.com/wxa/checksession?access_token=${access_token}&signature=${generateSignature(session_key)}&openid=${openid}&sig_method=hmac_sha256`; + const result = await axios.get(url); + return result.data.errcode === 0; + } + + /** + * 获取手机号 + * @param code + * @returns + */ + async getPhoneNumber(code: string): Promise { + const access_token: string = await this.getMiniAppAccessToken(); + + const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${access_token}`; + const result = await axios.post(url, { + code: code, + }); + + return result.data.phone_info.phoneNumber; + } + + /** + * 获取app的认证信息 + * @param code + * @returns + */ + async getAppAuthInfo(code: string): Promise<{ + access_token: string; + expires_in: number; + refresh_token: string; + openid: string; + scope: string; + unionid: string; + }> { + const key: string = `wx_auth_info:${code}`; + const orgValue = await this.redisService.get(key); + if (orgValue) return orgValue; + + const url = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${this.appId}&secret=${this.appSecret}&code=${code}&grant_type=authorization_code`; + const result = await axios.get(url); + + if (result.data) + this.redisService.setKey(key, result.data, result.data.expires_in); + + return result.data; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxGzh.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxGzh.service.ts new file mode 100644 index 000000000..0f54fab28 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxGzh.service.ts @@ -0,0 +1,166 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2025-02-26 10:12:02 + * @LastEditors: nevin + * @Description: 微信公众号服务 + */ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import * as crypto from 'crypto'; +import { RedisService } from '../redis/redis.service'; +import axios from 'axios'; + +@Injectable() +export class WxGzhService { + appId = ''; + secret = ''; + token = ''; + aesKey = ''; + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) { + this.appId = this.configService.get('WX_GZH.WX_GZH_ID'); + this.secret = this.configService.get('WX_GZH.WX_GZH_SECRET'); + this.token = this.configService.get('WX_GZH.WX_GZH_TOKEN'); + this.aesKey = this.configService.get('WX_GZH.WX_GZH_AES_KEY'); + } + + /** + * 获取access_token + * @returns + */ + private async getAccessToken(): Promise { + try { + const orgValue: string = await this.redisService.get( + `${this.appId}:access_token`, + ); + if (orgValue) return orgValue; + + const url = `https://api.weixin.qq.com/cgi-bin/stable_token`; + const result: { + data: { + access_token: string; + expires_in: number; + errcode: number; + errmsg: string; + }; + } = await axios.post(url, { + grant_type: 'client_credential', + appid: this.appId, + secret: this.secret, + }); + + if (!!result.data.errcode) + throw new Error(result.data.errcode + '---' + result.data.errmsg); + + if (result.data.access_token) { + await this.redisService.setKey( + `${this.appId}:access_token`, + result.data.access_token, + result.data.expires_in, + ); + } + + return result.data.access_token; + } catch (error: any) { + console.log('--------- getAccessToken ---- error', error); + + return ''; + } + } + + /** + * 创建二维码 + * @returns + */ + async createQrcode(sceneStr: string): Promise { + const orgValue: string = await this.redisService.get( + `${this.appId}:create_qrcode_ticket:${sceneStr}`, + ); + if (orgValue) return orgValue; + + const accessToken = await this.getAccessToken(); + if (!accessToken) return ''; + + try { + const url = `https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=${accessToken}`; + const result: { + data: { + errcode: number; + errmsg: string; + ticket: string; + expire_seconds: number; + url: string; + }; + } = await axios.post(url, { + expire_seconds: 60 * 8, + action_name: 'QR_STR_SCENE', + action_info: { scene: { scene_str: sceneStr } }, + }); + + if (!!result.data.errcode) + throw new Error(result.data.errcode + '---' + result.data.errmsg); + + if (!!result.data.ticket) { + await this.redisService.setKey( + `${this.appId}:create_qrcode_ticket:${sceneStr}`, + result.data.ticket, + result.data.expire_seconds, + ); + } + + return result.data.ticket; + } catch (error) { + console.log('--------- createQrcode ---- error', error); + return ''; + } + } + + /** + * 提供给微信进行验证 + * @returns + */ + async checkCallback(param: { + signature: string; + echostr: string; + timestamp: string; + nonce: string; + }): Promise { + const { signature, echostr, timestamp, nonce } = param; + + // 将 token、timestamp、nonce三个参数按字典序排序 + const str = [this.token, timestamp, nonce].sort().join(''); + + // 加密字符串, 建议使用 sha1加密 + const sha1 = crypto.createHash('sha1'); + sha1.update(str); + const sha1Str = sha1.digest('hex'); + + if (sha1Str === signature) { + console.log('验证成功'); + return echostr; + } else { + return 'error'; + } + } + + async doMsg( + param: { + signature: string; + timestamp: string; + nonce: string; + }, + xml: { + ToUserName: string; + FromUserName: string; + CreateTime: string; + MsgType: string; + Event: string; + EventKey: string; + }, + ): Promise { + return ''; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxPay.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxPay.service.ts new file mode 100644 index 000000000..33410d13e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/lib/wx/wxPay.service.ts @@ -0,0 +1,168 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 16:12:56 + * @LastEditTime: 2024-07-30 17:50:07 + * @LastEditors: nevin + * @Description: 微信服务 + */ +import { + HttpException, + HttpStatus, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import WxPay from 'wechatpay-node-v3'; +import { WECHAT_PAY_MANAGER } from 'nest-wechatpay-node-v3'; +import { sleep } from '../../util'; + +@Injectable() +export class WxPayService { + appId = ''; + mchId = ''; + notifyUrl = ''; + key = ''; + constructor( + private readonly configService: ConfigService, + @Inject(WECHAT_PAY_MANAGER) private wxPay: WxPay, + ) { + this.appId = this.configService.get('WX_CONFIG.APP_ID'); + this.mchId = this.configService.get('WX_CONFIG.MCH_ID'); + this.notifyUrl = this.configService.get('WX_CONFIG.NOTIFY_URL'); + this.key = this.configService.get('WX_CONFIG.KEY'); + } + + /** + * 小程序下单 + * @param openid + * @param money + * @returns + */ + async miniAppToPay( + openid: string, + money: number, + outTradeNo: string, + description: string, + ): Promise { + if (process.env.NODE_ENV !== 'production') { + money = 0.01; + } + const params = { + appid: this.appId, + mchid: this.mchId, + description: description, + out_trade_no: outTradeNo, + notify_url: this.notifyUrl, + amount: { + total: money * 100, + }, + payer: { + openid: openid, + }, + }; + const result = await this.wxPay.transactions_jsapi(params); + if (result.error) { + Logger.error(result.error); + return null; + } + + return result.data; + } + + async appToPay(money: number, outTradeNo: string, description: string) { + const params = { + appid: this.appId, + mchid: this.mchId, + description: description, + out_trade_no: outTradeNo, + notify_url: this.notifyUrl, + amount: { + total: money * 100, + // total: money, // TODO: 测试使用1分钱 + }, + payer: {}, + }; + const result = await this.wxPay.transactions_app(params); + if (result.error) { + Logger.error(result.error); + return null; + } + + return result.data; + } + + async decipherGcm( + ciphertext: string, + associated_data: string, + nonce: string, + key?: string, + ): Promise<{ + out_trade_no: string; + trade_state: string; // 'SUCCESS' + amount: { + total: number; + }; + }> { + try { + if (!key) key = this.key; + + return this.wxPay.decipher_gcm(ciphertext, associated_data, nonce, key); + } catch (error) { + console.log('===decipherGcm==', error); + Logger.error(error); + + return null; + } + } + + /** + * 发送红包 + * @param openId + * @param transAmount 金额 单位元 + * @param outBizNo 业务单号 + * @param retry + * @returns + */ + async sendRedPacket( + openId: string, + transAmount: number, + outBizNo: string, + retry = 0, + ) { + const result = await this.wxPay.batches_transfer({ + out_batch_no: outBizNo, + batch_name: '提现红包', + batch_remark: '提现红包', + total_amount: transAmount * 100, + total_num: 1, + transfer_detail_list: [ + { + out_detail_no: outBizNo, + transfer_amount: transAmount * 100, + transfer_remark: '提现红包', + openid: openId, + }, + ], + }); + + if (result.status === 200) { + return result; + } else if (result.error === 'SYSTEM_ERROR' && result.status === 500) { + Logger.error('系统错误', 'SYSTEM_ERROR', 'wxPayService', result); + if (retry > 5) { + throw new HttpException('系统错误', HttpStatus.INTERNAL_SERVER_ERROR); + } + await sleep((retry + 1) * 1000); + return await this.sendRedPacket( + openId, + transAmount, + outBizNo, + (retry = retry + 1), + ); + } else { + Logger.error('系统错误', 'wxPayService', result); + return null; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/main.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/main.ts new file mode 100644 index 000000000..4ea29a343 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/main.ts @@ -0,0 +1,72 @@ +/* + * @Author: nevin + * @Date: 2025-01-15 14:17:16 + * @LastEditTime: 2025-02-25 22:17:43 + * @LastEditors: nevin + * @Description: + */ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { ConfigService } from '@nestjs/config'; +import { createSwagger } from './_swagger'; +import { + BadRequestException, + HttpStatus, + ValidationPipe, +} from '@nestjs/common'; +import { join } from 'path'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + bufferLogs: true, + }); + const config = app.get(ConfigService); + + app.enableCors({ + origin: '*', + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS', + preflightContinue: false, + optionsSuccessStatus: 200, + }); + + app.setGlobalPrefix('api', { exclude: ['/'] }); // 路由添加api开头 + + const { ENABLE_SWAGGER, NODE_ENV, PORT } = config.get('SERVER_CONFIG'); + + const docsUrl = ENABLE_SWAGGER ? createSwagger(app) : ''; // 文档插件 + + app.useGlobalPipes( + new ValidationPipe({ + transform: true, + whitelist: true, + transformOptions: { enableImplicitConversion: true }, + // forbidNonWhitelisted: true, // 禁止 无装饰器验证的数据通过 + errorHttpStatusCode: HttpStatus.BAD_REQUEST, + stopAtFirstError: true, + exceptionFactory: (errors) => + new BadRequestException( + errors.map((e) => { + const rule = Object.keys(e.constraints!)[0]; + const msg = e.constraints![rule]; + return msg; + })[0], + ), + }), + ); + app.useBodyParser('json', { limit: '50mb' }); + app.useBodyParser('urlencoded', { limit: '50mb', extended: true }); + app.setBaseViewsDir(join(__dirname, '..', 'views')); + app.setViewEngine('hbs'); + // app.useGlobalInterceptors( + // new TransformInterceptor(), + // new LoggingInterceptor(), + // ); // 全局注册拦截器 + // app.useGlobalFilters(new HttpExceptionFilter()); // 全局错误拦截器 + await app.listen(PORT); + console.info( + `Application-${NODE_ENV} is running on: http://127.0.0.1:${PORT}`, + ); + console.info(`Swagger Docs: http://127.0.0.1:${PORT}${docsUrl}`); +} +bootstrap(); diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/dto/manager.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/dto/manager.dto.ts new file mode 100644 index 000000000..db6b4b2f0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/dto/manager.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class LoginDto { + @ApiProperty({ description: '账号' }) + @IsString() + @IsNotEmpty() + account: string; + + @ApiProperty({ description: '密码' }) + @IsString() + @IsNotEmpty() + password: string; +} + +export class CreateManagerDto { + @ApiProperty({ description: '账号' }) + @IsString() + @IsNotEmpty() + account: string; + + @ApiProperty({ description: '密码' }) + @IsString() + @IsNotEmpty() + password: string; + + @ApiProperty({ description: '姓名' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ description: '手机号', required: false }) + @IsString() + @IsOptional() + phone?: string; +} + +export class UpdateManagerDto { + @ApiProperty({ description: '姓名', required: false }) + @IsString() + @IsOptional() + name?: string; + + @ApiProperty({ description: '手机号', required: false }) + @IsString() + @IsOptional() + phone?: string; + + @ApiProperty({ description: '头像', required: false }) + @IsString() + @IsOptional() + avatar?: string; + + @ApiProperty({ description: '密码', required: false }) + @IsString() + @IsOptional() + password?: string; + + @ApiProperty({ description: '盐', required: false }) + @IsString() + @IsOptional() + salt?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.controller.ts new file mode 100644 index 000000000..19ea8d810 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.controller.ts @@ -0,0 +1,92 @@ +import { Body, Controller, Delete, Get, Post, Put } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ManagerService } from './manager.service'; +import { + CreateManagerDto, + LoginDto, + UpdateManagerDto, +} from './dto/manager.dto'; +import { AuthService } from '../auth/auth.service'; +import { Manager } from '../auth/manager.guard'; +import { GetToken, Public } from '../auth/auth.guard'; +import { TokenInfo } from '../auth/interfaces/auth.interfaces'; +import { validatePassWord } from '../util/password.util'; +import { AppHttpException } from '../filters/http-exception.filter'; +import { ErrHttpBack } from '../filters/http-exception.back-code'; + +@ApiTags('manager - 管理员') +@Controller('manager') +export class ManagerController { + constructor( + private readonly managerService: ManagerService, + private readonly authService: AuthService, + ) {} + + @ApiOperation({ summary: '管理员登录' }) + @Public() + @Post('login') + async login(@Body() loginDto: LoginDto) { + const manager = await this.managerService.findByAccount(loginDto.account); + if (!manager) { + throw new AppHttpException(ErrHttpBack.err_user_no_had); + } + + const isValid = validatePassWord( + manager.password, + manager.salt, + loginDto.password, + ); + if (!isValid) { + throw new AppHttpException(ErrHttpBack.err_no_power_login); + } + + const token = await this.authService.generateToken({ + id: manager.id, + phone: manager.phone || '', + name: manager.name, + isManager: true, + }); + + return { + token, + managerInfo: manager, + }; + } + + @ApiOperation({ summary: '获取管理员信息' }) + @Manager() + @Get('info') + async getInfo(@GetToken() token: TokenInfo) { + return this.managerService.findById(token.id); + } + + @ApiOperation({ summary: '创建管理员' }) + @Manager() + @Post() + async create(@Body() createManagerDto: CreateManagerDto) { + const existManager = await this.managerService.findByAccount( + createManagerDto.account, + ); + if (existManager) { + throw new AppHttpException(ErrHttpBack.err_user_had); + } + return this.managerService.create(createManagerDto); + } + + @ApiOperation({ summary: '更新管理员信息' }) + @Manager() + @Put() + async update( + @GetToken() token: TokenInfo, + @Body() updateManagerDto: UpdateManagerDto, + ) { + return this.managerService.update(token.id, updateManagerDto); + } + + @ApiOperation({ summary: '删除管理员' }) + @Manager() + @Delete() + async delete(@GetToken() token: TokenInfo) { + return this.managerService.delete(token.id); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.module.ts new file mode 100644 index 000000000..29cb1a46f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ManagerController } from './manager.controller'; +import { ManagerService } from './manager.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Manager, ManagerSchema } from '../db/schema/manager.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Manager.name, schema: ManagerSchema }]), + ], + controllers: [ManagerController], + providers: [ManagerService], + exports: [ManagerService], +}) +export class ManagerModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.service.ts new file mode 100644 index 000000000..835287217 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/manager/manager.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Manager, ManagerStatus } from '../db/schema/manager.schema'; +import { CreateManagerDto, UpdateManagerDto } from './dto/manager.dto'; +import { encryptPassword } from '../util/password.util'; + +@Injectable() +export class ManagerService { + constructor( + @InjectModel(Manager.name) + private readonly managerModel: Model, + ) {} + + async findByAccount(account: string) { + return this.managerModel.findOne({ + account, + status: ManagerStatus.OPEN, + }); + } + + async findById(id: string) { + return this.managerModel.findById(id); + } + + async create(createManagerDto: CreateManagerDto) { + const { password, salt } = encryptPassword(createManagerDto.password); + const manager = new this.managerModel({ + ...createManagerDto, + password, + salt, + }); + return manager.save(); + } + + async update(id: string, updateManagerDto: UpdateManagerDto) { + if (updateManagerDto.password) { + const { password, salt } = encryptPassword(updateManagerDto.password); + updateManagerDto.password = password; + updateManagerDto.salt = salt; + } + return this.managerModel.findByIdAndUpdate( + id, + { $set: updateManagerDto }, + { new: true }, + ); + } + + async delete(id: string) { + return this.managerModel.findByIdAndUpdate( + id, + { $set: { status: ManagerStatus.DELETE } }, + { new: true }, + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/middleware/xml.middleware.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/middleware/xml.middleware.ts new file mode 100644 index 000000000..03ce975b3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/middleware/xml.middleware.ts @@ -0,0 +1,34 @@ +/* + * @Author: nevin + * @Date: 2025-02-25 14:39:09 + * @LastEditTime: 2025-02-25 21:34:03 + * @LastEditors: nevin + * @Description: + */ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import * as bodyParser from 'body-parser'; +import { NextFunction } from 'express'; +import * as xml2js from 'xml2js'; + +@Injectable() +export class XMLMiddleware implements NestMiddleware { + private xmlParser = bodyParser.text({ type: 'text/xml' }); + + use(req: any, res: any, next: NextFunction) { + this.xmlParser(req, res, (err) => { + if (err) return next(err); + + if (req.body) { + xml2js.parseString( + req.body, + { explicitArray: false }, + (parseErr, result) => { + if (parseErr) return next(parseErr); + req.body = result.xml; + }, + ); + } + next(); + }); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.controller.ts new file mode 100644 index 000000000..43d7e66e9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.controller.ts @@ -0,0 +1,164 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AccountService } from './account.service'; +import { Account } from '../../db/schema/account.schema'; +import { ApiResult } from '../../common/decorators/api-result.decorator'; +import { + AccountIdDto, + AccountListByIdsDto, + AccountStatisticsDto, + CreateAccountDto, + UpdateAccountStatusDto, + UpdateAccountStatisticsDto, + GoogleLoginDto, + DeleteAccountsDto, + UpdateAccountDto, +} from './dto/account.dto'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { GetToken } from 'src/auth/auth.guard'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { Public } from '../../auth/auth.guard'; +import { AccountGroup } from '../../db/schema/accountGroup.schema'; +import { DeleteAccountGroupDto } from './accountGroup/dto/accountGroup.dto'; + +@ApiTags('账户') +@Controller('account') +export class AccountController { + constructor(private readonly accountService: AccountService) {} + + @ApiOperation({ summary: '创建账号' }) + @Post('login') + @ApiResult({ type: Account }) + async createOrUpdateAccount( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: CreateAccountDto, + ) { + return this.accountService.addOrUpdateAccount({ + userId: token.id, + ...body, + }); + } + + @ApiOperation({ summary: '创建或更新账号' }) + @Post('update') + @ApiResult({ type: Account }) + async updateOrUpdateAccount( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: UpdateAccountDto, + ) { + return this.accountService.addOrUpdateAccount({ + userId: token.id, + ...body, + }); + } + + @ApiOperation({ summary: '更新账号状态' }) + @Post('status') + @ApiResult({ type: Account }) + async updateAccountStatus( + @Body(new ParamsValidationPipe()) body: UpdateAccountStatusDto, + ) { + return this.accountService.updateAccountStatus(body.id, body.status); + } + + @ApiOperation({ summary: '获取账号信息' }) + @Get(':id') + @ApiResult({ type: Account }) + async getAccountInfo(@Param(new ParamsValidationPipe()) param: AccountIdDto) { + return this.accountService.getAccountById(param.id); + } + + @ApiOperation({ summary: '获取用户所有账户' }) + @Get('list/all') + @ApiResult({ type: [Account] }) + async getUserAccounts(@GetToken() token: TokenInfo) { + return this.accountService.getAccounts(token.id); + } + + @ApiOperation({ summary: '删除多个账户' }) + @Post('deletes') + @ApiResult({ type: AccountGroup }) + async deletes( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: DeleteAccountsDto, + ) { + return this.accountService.deleteAccounts(body.ids, token.id); + } + + @ApiOperation({ summary: '获取账户列表' }) + @Post('list/ids') + @ApiResult({ type: Account }) + async getAccountListByIds( + @GetToken() token: TokenInfo, + @Query(new ParamsValidationPipe()) query: AccountListByIdsDto, + ) { + return this.accountService.getAccountListByIds(token.id, query.ids); + } + + @ApiOperation({ summary: '获取账户总数' }) + @Get('count') + @ApiResult({ type: Number }) + async getAccountCount(@GetToken() token: TokenInfo) { + return this.accountService.getAccountCount(token.id); + } + + @ApiOperation({ summary: '获取账户统计' }) + @Get('statistics') + @ApiResult({ type: Account }) + async getAccountStatistics( + @GetToken() token: TokenInfo, + @Query(new ParamsValidationPipe()) query: AccountStatisticsDto, + ) { + return this.accountService.getAccountStatistics(token.id, query.type); + } + + @ApiOperation({ summary: '删除账户' }) + @Post('delete/:id') + @ApiResult({ type: Boolean }) + async deleteAccount( + @GetToken() token: TokenInfo, + @Param(new ParamsValidationPipe()) param: AccountIdDto, + ) { + return this.accountService.deleteAccount(param.id, token.id); + } + + @ApiOperation({ summary: '更新账户统计信息' }) + @Post('statistics/update') + @ApiResult({ type: Boolean }) + async updateAccountStatistics( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: UpdateAccountStatisticsDto, + ) { + const { + id, + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + } = body; + return this.accountService.updateAccountStatistics( + id, + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + ); + } + + // @ApiOperation({ summary: 'Google登录' }) + // @Post('login/google') + // @Public() + // @ApiResult({ type: Account }) + // async googleLogin( + // @Body(new ParamsValidationPipe()) body: GoogleLoginDto, + // ) { + // // console.log(body.clientId, body.credential) + // return this.accountService.googleLogin(body.clientId, body.credential); + // } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.module.ts new file mode 100644 index 000000000..3d5ab67d5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.module.ts @@ -0,0 +1,25 @@ +import { Global, Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Account, AccountSchema } from '../../db/schema/account.schema'; +import { AccountService } from './account.service'; +import { AccountController } from './account.controller'; +import { AccountGroupController } from './accountGroup/accountGroup.controller'; +import { AccountGroupService } from './accountGroup/accountGroup.service'; +import { + AccountGroup, + AccountGroupSchema, +} from '../../db/schema/accountGroup.schema'; + +@Global() +@Module({ + imports: [ + MongooseModule.forFeature([{ name: Account.name, schema: AccountSchema }]), + MongooseModule.forFeature([ + { name: AccountGroup.name, schema: AccountGroupSchema }, + ]), + ], + providers: [AccountService, AccountGroupService], + controllers: [AccountController, AccountGroupController], + exports: [AccountService, AccountGroupService], +}) +export class AccountModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.service.ts new file mode 100644 index 000000000..ca40f6d5f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/account.service.ts @@ -0,0 +1,291 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { Account, AccountType } from '../../db/schema/account.schema'; +import { IdService } from 'src/db/id.service'; + +import { AccountGroupService } from './accountGroup/accountGroup.service'; + +@Injectable() +export class AccountService { + + constructor( + @InjectModel(Account.name) + private readonly accountModel: Model, + + private readonly idService: IdService, + @Inject(forwardRef(() => AccountGroupService)) + private readonly accountGroupService: AccountGroupService, + ) { + } + + private async getId() { + return this.idService.createId('accountId', 100000000, 1); + } + + /** + * 将用户组下的账户切换到默认组 + * @param userId + * @param groupId + * @param defaultGroupId + */ + async switchToDefaultGroup( + userId: string, + groupId: number, + defaultGroupId: number, + ) { + return this.accountModel.updateMany( + { userId, groupId: { $ne: groupId } }, + { groupId: defaultGroupId }, + ); + } + + /** + * 添加或更新账号 + * @param account + * @returns + */ + async addOrUpdateAccount(account: Partial): Promise { + const existingAccount = await this.accountModel.findOne( + account.id + ? { + id: account.id, + } + : { + userId: account.userId, + uid: account.uid, + type: account.type, + }, + ); + + if (existingAccount) { + return this.accountModel.findOneAndUpdate( + { id: existingAccount.id }, + account, + { + new: true, + }, + ); + } + + // 获取默认用户组 + let defaultGrpoupId: number; + const defaultGroup = await this.accountGroupService.getDefaultGroup( + account.userId, + ); + if (defaultGroup) { + defaultGrpoupId = defaultGroup.id; + } else { + // 如果没有默认用户组,则创建一个默认用户组 + const newGroup = await this.accountGroupService.createDefaultGroup( + account.userId, + ); + defaultGrpoupId = newGroup.id; + } + + account.id = await this.getId(); + account.groupId = defaultGrpoupId; + return this.accountModel.create(account); + } + + /** + * 根据用户id获取账号 + */ + async getAccountById(id: number) { + return this.accountModel.findOne({ id }); + } + + /** + * 获取所有账户 + * @param userId + * @returns + */ + async getAccounts(userId: string) { + return await this.accountModel.find({ + userId, + }); + } + + /** + * 根据ID数组ids获取账户列表数组 + * @param userId + * @param ids + * @returns + */ + async getAccountListByIds(userId: string, ids: number[]) { + return await this.accountModel.find({ + userId, + id: { $in: ids }, + }); + } + + /** + * 获取账户的统计信息 + * @param userId + * @param type + * @returns + */ + async getAccountStatistics( + userId: string, + type?: AccountType, + ): Promise<{ + accountTotal: number; + list: Account[]; + fansCount?: number; + readCount?: number; + likeCount?: number; + collectCount?: number; + commentCount?: number; + income?: number; + }> { + const accountList = await this.accountModel.find({ + userId, + ...(type && { type }), + }); + + const res = { + accountTotal: accountList.length, + list: accountList, + fansCount: 0, + }; + + for (const element of accountList) { + // TODO: 获取统计信息 + // const ret = await platController.getStatistics(element).catch((err) => { + // console.error(err); + // }); + // res.fansCount += ret?.fansCount || 0; + } + + return res; + } + + /** + * 获取用户的账户总数 + * @param userId + * @returns + */ + async getAccountCount(userId: string) { + return await this.accountModel.countDocuments({ userId }); + } + + /** + * 根据多个账户id查询账户信息 + * @param ids + * @returns + */ + async getAccountsByIds(ids: number[]) { + return await this.accountModel.find({ + id: { $in: ids }, + }); + } + + /** + * 更新粉丝数量 + * @param userId + * @param account + * @param fansCount + * @returns + */ + async updateFansCount(userId: string, account: string, fansCount: number) { + return await this.accountModel.updateOne( + { userId, account }, + { fansCount: fansCount }, + ); + } + + // 获取用户的所有账户的总粉丝量 + async getUserFansCount(userId: string) { + const accounts = await this.accountModel.find({ userId }); + return accounts.reduce((acc, cur) => acc + (cur.fansCount || 0), 0); + } + + /** + * 删除 + * @param id + * @param userId + * @returns + */ + async deleteAccount(id: number, userId: string): Promise { + const res = await this.accountModel.deleteOne({ + id, + userId: userId, + }); + + return res.deletedCount > 0; + } + + // 删除多个账户 + async deleteAccounts(ids: string[], userId: string) { + const res = await this.accountModel.deleteMany({ + _id: { $in: ids }, + userId, + }); + return res.deletedCount > 0; + } + + /** + * 更新用户状态 + * @param id + * @param status + * @returns + */ + async updateAccountStatus(id: number, status: number) { + return await this.accountModel.updateOne({ id }, { status }); + } + + // 更新账户的统计信息 + async updateAccountStatistics( + id: number, + fansCount: number, + readCount: number, + likeCount: number, + collectCount: number, + commentCount: number, + income: number, + workCount: number, + ) { + return await this.accountModel.updateOne( + { id }, + { + fansCount, + readCount, + likeCount, + collectCount, + commentCount, + income, + workCount, + }, + ); + } + + // /** + // * Google登录 + // * @param clientId Google客户端ID + // * @param credential Google认证凭证 + // * @returns Account + // */ + // async googleLogin(clientId: string, credential: string): Promise { + // try { + // console.log('Verifying Google token with:'); + + // // 验证Google token + // const ticket = await this.googleClient.verifyIdToken({ + // idToken: credential, + // audience: clientId, + // }); + // console.log('ticket',ticket) + // const payload = ticket.getPayload(); + // console.log('payload',payload) + // if (!payload) { + // throw new Error('Invalid Google token'); + // } + + // console.log('Google login success, payload:', payload); + // return payload; + // } catch (error) { + // console.error('Google login error:', error); + // throw new Error(`Google login failed: ${error.message}`); + // } + // } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.controller.ts new file mode 100644 index 000000000..0e619dc97 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.controller.ts @@ -0,0 +1,63 @@ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AccountGroupService } from './accountGroup.service'; +import { ApiResult } from '../../../common/decorators/api-result.decorator'; +import { AccountGroup } from '../../../db/schema/accountGroup.schema'; +import { GetToken } from '../../../auth/auth.guard'; +import { TokenInfo } from '../../../auth/interfaces/auth.interfaces'; +import { ParamsValidationPipe } from '../../../validation.pipe'; +import { + CreateAccountGroupDto, + DeleteAccountGroupDto, + UpdateAccountGroupDto, +} from './dto/accountGroup.dto'; +import { Account } from '../../../db/schema/account.schema'; + +@ApiTags('账户组') +@Controller('accountGroup') +export class AccountGroupController { + constructor(private readonly accountGroupService: AccountGroupService) {} + + @ApiOperation({ summary: '创建组' }) + @Post('create') + @ApiResult({ type: AccountGroup }) + async create( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: CreateAccountGroupDto, + ) { + return this.accountGroupService.addOrUpdateAccountGroup({ + userId: token.id, + ...body, + }); + } + + @ApiOperation({ summary: '更新组' }) + @Post('update') + @ApiResult({ type: AccountGroup }) + async update( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: UpdateAccountGroupDto, + ) { + return this.accountGroupService.addOrUpdateAccountGroup({ + userId: token.id, + ...body, + }); + } + + @ApiOperation({ summary: '删除账户组' }) + @Post('deletes') + @ApiResult({ type: AccountGroup }) + async deletes( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: DeleteAccountGroupDto, + ) { + return this.accountGroupService.deleteAccountGroup(body.ids, token.id); + } + + @ApiOperation({ summary: '获取用户所有账户组' }) + @Get('getList') + @ApiResult({ type: [Account] }) + async getUserAccounts(@GetToken() token: TokenInfo) { + return this.accountGroupService.getAccountGroup(token.id); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.service.ts new file mode 100644 index 000000000..4538ab27e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/accountGroup.service.ts @@ -0,0 +1,113 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { + AccountGroup, + AccountGroupDefaultType, +} from '../../../db/schema/accountGroup.schema'; +import { AccountService } from '../account.service'; +import { IdService } from '../../../db/id.service'; + +@Injectable() +export class AccountGroupService { + constructor( + @InjectModel(AccountGroup.name) + private readonly accountGroupModel: Model, + @Inject(forwardRef(() => AccountService)) + private readonly accountService: AccountService, + private readonly idService: IdService, + ) {} + + private async getId() { + return this.idService.createId('accountGroupId', 100000000, 1); + } + + /** + * 添加或者更新组 + * @param accountGroup + */ + async addOrUpdateAccountGroup( + accountGroup: Partial, + ): Promise { + // 更新数据 + if (accountGroup.id) { + return this.accountGroupModel.findOneAndUpdate( + { id: accountGroup.id }, + accountGroup, + { + new: true, + }, + ); + } + + accountGroup.id = await this.getId(); + // 添加数据 + return this.accountGroupModel.create(accountGroup); + } + + /** + * 删除多个组 + * @param ids + * @param userId + */ + async deleteAccountGroup(ids: number[], userId: string): Promise { + const accountGorupList = await this.accountGroupModel + .find({ userId, id: { $in: ids } }) + .exec(); + // 默认用户组 + const defaultGroup = await this.getDefaultGroup(userId); + + // 将删除的组下面的账户切换为默认组 + for (const gorup of accountGorupList) { + await this.accountService.switchToDefaultGroup( + userId, + gorup.id, + defaultGroup.id, + ); + } + + // 删除 + const res = await this.accountGroupModel.deleteMany({ + id: { $in: ids }, + userId, + }); + return res.deletedCount > 0; + } + + // 获取默认用户组 + async getDefaultGroup(userId: string) { + return this.accountGroupModel + .findOne({ + userId, + isDefault: AccountGroupDefaultType.Default, + }) + .exec(); + } + + // 创建默认用户组 + async createDefaultGroup(userId: string): Promise { + return this.addOrUpdateAccountGroup({ + name: '默认列表', + rank: 0, + createTime: new Date(), + updateTime: new Date(), + userId, + isDefault: AccountGroupDefaultType.Default, + }); + } + + // 获取所有组 + async getAccountGroup(userId: string): Promise { + const accountGroupList = await this.accountGroupModel + .find({ userId }) + .exec(); + + // 创建默认用户组 + if (accountGroupList.length === 0) { + const accountGroup = await this.createDefaultGroup(userId); + return [accountGroup]; + } + + return accountGroupList; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/dto/accountGroup.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/dto/accountGroup.dto.ts new file mode 100644 index 000000000..72751104b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/accountGroup/dto/accountGroup.dto.ts @@ -0,0 +1,32 @@ +// 创建组 Dto +import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class CreateAccountGroupDto { + @ApiProperty({ description: '组名称' }) + @IsString() + @Expose() + name: string; + + @ApiProperty({ description: '组排序,默认为 1' }) + @IsNumber() + @IsOptional() + @Expose() + rank?: number; +} + +export class UpdateAccountGroupDto extends CreateAccountGroupDto { + @ApiProperty({ description: '更新ID' }) + @IsNumber() + @Expose() + id: number; +} + +export class DeleteAccountGroupDto { + @ApiProperty({ description: '要删除的ID' }) + @IsArray() + @IsNumber({}, { each: true }) + @Expose() + ids: number[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/dto/account.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/dto/account.dto.ts new file mode 100644 index 000000000..c28699502 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/account/dto/account.dto.ts @@ -0,0 +1,219 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { + IsArray, + IsDate, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { Expose } from 'class-transformer'; +import { AccountStatus, AccountType } from '../../../db/schema/account.schema'; + +export class CreateAccountDto { + @ApiProperty({ description: '平台类型', enum: AccountType }) + @IsEnum(AccountType) + @Expose() + type: AccountType; + + @ApiProperty({ description: '登录Cookie' }) + @IsString() + @Expose() + loginCookie: string; + + @ApiProperty({ description: '登录时间' }) + @IsOptional() + @IsDate() + @Expose() + loginTime?: Date; + + @ApiProperty({ description: '平台用户ID' }) + @IsString() + @Expose() + uid: string; + + @ApiProperty({ description: '账号' }) + @IsString() + @Expose() + account: string; + + @ApiProperty({ description: '头像' }) + @IsString() + @Expose() + avatar: string; + + @ApiProperty({ description: '昵称' }) + @IsString() + @Expose() + nickname: string; + + @ApiProperty({ description: '粉丝数' }) + @IsNumber() + @IsOptional() + @Expose() + fansCount?: number; + + @ApiProperty({ description: '阅读数' }) + @IsNumber() + @IsOptional() + @Expose() + readCount?: number; + + @ApiProperty({ description: '点赞数' }) + @IsNumber() + @IsOptional() + @Expose() + likeCount?: number; + + @ApiProperty({ description: '收藏数' }) + @IsNumber() + @IsOptional() + @Expose() + collectCount?: number; + + @ApiProperty({ description: '转发数' }) + @IsNumber() + @IsOptional() + @Expose() + forwardCount?: number; + + @ApiProperty({ description: '评论数' }) + @IsNumber() + @IsOptional() + @Expose() + commentCount?: number; + + @ApiProperty({ description: '最后统计时间' }) + @IsDate() + @IsOptional() + @Expose() + lastStatsTime?: Date; + + @ApiProperty({ description: '作品数' }) + @IsNumber() + @IsOptional() + @Expose() + workCount?: number; + + @ApiProperty({ description: '收入' }) + @IsNumber() + @IsOptional() + @Expose() + income?: number; + + @ApiProperty({ description: '账户组ID' }) + @IsNumber() + @IsOptional() + @Expose() + groupId?: number; +} + +export class UpdateAccountDto extends PartialType(CreateAccountDto) { + @ApiProperty({ description: '更新ID' }) + @IsNumber() + @Expose() + id: number; +} + +export class AccountIdDto { + @ApiProperty({ description: '账号ID' }) + @IsNumber() + @Expose() + id: number; +} + +export class UpdateAccountStatusDto extends AccountIdDto { + @ApiProperty({ description: '状态' }) + @IsEnum(AccountStatus, { + message: `status must be one of these values: ${Object.values( + AccountStatus, + ).join(', ')}`, + }) + @Expose() + status: AccountStatus; +} + +export class AccountListByIdsDto { + @ApiProperty({ description: '账号ID数组', type: [Number] }) + @IsArray() + @IsNumber({}, { each: true }) + @Expose() + ids: number[]; +} + +export class AccountStatisticsDto { + @ApiProperty({ description: '账户类型', enum: AccountType, required: false }) + @IsEnum(AccountType) + @IsOptional() + @Expose() + type?: AccountType; +} + +export class UpdateAccountStatisticsDto { + @ApiProperty({ description: '账号ID' }) + @IsNumber() + @Expose() + id: number; + + @ApiProperty({ description: '作品数' }) + @IsNumber() + @IsOptional() + @Expose() + workCount?: number; + + @ApiProperty({ description: '粉丝数' }) + @IsNumber() + @IsOptional() + @Expose() + fansCount?: number; + + @ApiProperty({ description: '阅读数' }) + @IsNumber() + @IsOptional() + @Expose() + readCount?: number; + + @ApiProperty({ description: '点赞数' }) + @IsNumber() + @IsOptional() + @Expose() + likeCount?: number; + + @ApiProperty({ description: '收藏数' }) + @IsNumber() + @IsOptional() + @Expose() + collectCount?: number; + + @ApiProperty({ description: '评论数' }) + @IsNumber() + @IsOptional() + @Expose() + commentCount?: number; + + @ApiProperty({ description: '收入' }) + @IsNumber() + @IsOptional() + @Expose() + income?: number; +} + +export class GoogleLoginDto { + @ApiProperty({ description: 'Google客户端ID' }) + @IsString() + @Expose() + clientId: string; + + @ApiProperty({ description: 'Google认证凭证' }) + @IsString() + @Expose() + credential: string; +} + +export class DeleteAccountsDto { + @ApiProperty({ description: '要删除的ID' }) + @IsArray() + @IsNumber({}, { each: true }) + @Expose() + ids: string[]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/adminFinance.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/adminFinance.controller.ts new file mode 100644 index 000000000..f66192050 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/adminFinance.controller.ts @@ -0,0 +1,62 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-03-02 23:45:48 + * @LastEditors: nevin + * @Description: 管理员-财务 + */ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { FinanceService } from './finance.service'; +import { + GetUserWalletRecordListByAdminDto, + UpUserWalletRecordPul, +} from './dto/userWalletRecord.dto'; +import { ObjectId } from 'mongodb'; +import { UserService } from 'src/user/user.service'; + +@Controller('adminFinance') +export class AdminFinanceController { + constructor( + private readonly financeService: FinanceService, + private readonly userService: UserService, + ) {} + + // 获取用户账户信息 + @Get('userWallet/info/:userId') + async getUserWalletInfoByUserId(@Param() param: { userId: string }) { + const user = await this.userService.getUserInfoById(param.userId); + if (!user) return null; + + return this.financeService.getUserWalletByUserId( + new ObjectId(param.userId), + ); + } + + // --------- userWalletRecord STR --------- + // 获取用户账户记录列表 + @Get('userWalletRecord/list') + async getUserWalletRecordList( + @Query() query: GetUserWalletRecordListByAdminDto, + ) { + return this.financeService.getWalletRecordList(query); + } + + // 提交记录发放 + @Post('userWalletRecord/submit/:id') + async submitUserWalletRecord( + @Param('id') id: string, + @Body() body: UpUserWalletRecordPul, + ) { + return this.financeService.submitUserWalletRecord(id, body); + } + + // 打款拒绝 + @Post('userWalletRecord/reject/:id') + async rejectUserWalletRecord( + @Param('id') id: string, + @Body() body: UpUserWalletRecordPul, + ) { + return this.financeService.rejectUserWalletRecord(id, body); + } + // --------- userWalletRecord END --------- +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletAccount.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletAccount.dto.ts new file mode 100644 index 000000000..df9c2c7a8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletAccount.dto.ts @@ -0,0 +1,46 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 12:11:19 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { + UserWalletAccount, + WalletAccountType, +} from 'src/db/schema/userWalletAccount.shema'; + +export class CreateUserWalletAccountDto implements Partial { + @ApiProperty({ type: String }) + @IsString({ message: '账号号码必须是字符串' }) + @IsOptional() + @Expose() + account?: string; + + @ApiProperty({ type: String }) + @IsNotEmpty({ message: '姓名不能为空' }) + @IsString() + @Expose() + userName: string; + + @ApiProperty({ type: String }) + @IsNotEmpty({ message: '身份证不能为空' }) + @IsString() + @Expose() + cardNum: string; + + @ApiProperty({ type: String }) + @IsNotEmpty({ message: '手机号不能为空' }) + @IsString() + @Expose() + phone: string; + + @ApiProperty({ enum: WalletAccountType }) + @IsNotEmpty({ message: '钱包类型不能为空' }) + @IsEnum(WalletAccountType) + @Expose() + type: WalletAccountType; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletRecord.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletRecord.dto.ts new file mode 100644 index 000000000..b22e9a551 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/dto/userWalletRecord.dto.ts @@ -0,0 +1,99 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-03-24 21:52:23 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDateString, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { PagerDto } from 'src/common/dto/pager.dto'; +import { + UserWalletRecordStatus, + UserWalletRecordType, +} from 'src/db/schema/userWalletRecord.shema'; + +export class GetUserWalletRecordListDto extends PagerDto { + @ApiProperty({ title: '创建时间区间', required: false }) + @IsArray({ message: '创建时间区间必须是一个数组' }) + @ArrayMinSize(2, { message: '创建时间区间必须包含两个日期' }) + @ArrayMaxSize(2, { message: '创建时间区间必须包含两个日期' }) + // @IsDate({ each: true, message: '创建时间区间中的每个元素必须是有效的日期' }) + @IsDateString({}, { each: true }) + @IsOptional() + @Expose() + readonly time?: [Date, Date]; + + @ApiProperty({ enum: UserWalletRecordType }) + @IsEnum(UserWalletRecordType) + @IsOptional() + @Expose() + type?: UserWalletRecordType; +} + +export class GetUserWalletRecordListByAdminDto extends PagerDto { + // 起始时间 + @ApiProperty({ type: [String] }) + @Expose() + time?: [string, string]; + + @ApiProperty({ enum: UserWalletRecordType }) + @Expose() + type?: UserWalletRecordType; + + @ApiProperty({ enum: UserWalletRecordStatus }) + @Expose() + status?: UserWalletRecordStatus; + + @ApiProperty({ type: String }) + @Expose() + userId?: string; +} + +export class UpUserWalletRecordPul { + @IsString({ + message: '说明', + }) + @IsOptional() + @Expose() + des?: string; + + @IsString({ + message: '反馈截图', + }) + @IsOptional() + @Expose() + imgUrl?: string; // 反馈截图 +} + +export class CreateUserWalletRecordDto { + @ApiProperty({ type: String, description: '钱包账户id', required: true }) + @IsString({ + message: '钱包账户id必须为字符串', + }) + @Expose() + walletAccountId: string; + + @ApiProperty({ type: Number, description: '金额', required: true }) + @IsNumber( + { + allowNaN: false, + }, + { message: '金额必须为数字' }, + ) + @Expose() + balance: number; + + @ApiProperty({ enum: UserWalletRecordStatus }) + status?: UserWalletRecordStatus; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.controller.ts new file mode 100644 index 000000000..d2ff0ffd5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.controller.ts @@ -0,0 +1,157 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 14:07:03 + * @LastEditors: nevin + * @Description: 财务 + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Query, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiResult } from '../../common/decorators/api-result.decorator'; +import { FinanceService } from './finance.service'; +import { GetToken, Public } from '../../auth/auth.guard'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { CreateUserWalletAccountDto } from './dto/userWalletAccount.dto'; +import { UserWalletAccount } from 'src/db/schema/userWalletAccount.shema'; +import { + CreateUserWalletRecordDto, + GetUserWalletRecordListDto, +} from './dto/userWalletRecord.dto'; +import { + UserWalletRecordStatus, + UserWalletRecordType, +} from 'src/db/schema/userWalletRecord.shema'; +import { UserWallet } from 'src/db/schema/userWallet.shema'; +import { ObjectId } from 'mongodb'; +import { AppHttpException } from 'src/filters/http-exception.filter'; +import { ErrHttpBack } from 'src/filters/http-exception.back-code'; +import { RealAuthService } from '../tools/realAuth.service'; +import { SceneType } from 'src/db/schema/realAuth.schema'; + +@ApiTags('finance - 财务') +@Controller('finance') +export class FinanceController { + constructor( + private readonly financeService: FinanceService, + private readonly realAuthService: RealAuthService, + ) {} + + // --------- userWalletAccount STR --------- + @ApiOperation({ summary: '发送创建账户的短信码' }) + @ApiResult({ type: String }) + @Public() + @Post('userWalletAccount/phoneCode/:phone') + async postCreateUserWalletAccountCode(@Param('phone') phone: string) { + return await this.financeService.postCreateUserWalletAccountCode(phone); + } + + @ApiOperation({ summary: '创建用户钱包账户' }) + @Post('userWalletAccount') + @ApiResult({ type: UserWalletAccount }) + async createUserWalletAccount( + @GetToken() token: TokenInfo, + @Body() body: CreateUserWalletAccountDto, + ) { + // 验证身份证 + const res = await this.realAuthService.realNameAuth( + token.id, + body.cardNum, + body.userName, + SceneType.UserWallet, + ); + if (!res) + throw new AppHttpException(ErrHttpBack.err_approve_idcard_invalid); + + return await this.financeService.createUserWalletAccount(token.id, body); + } + + @ApiOperation({ summary: '获取用户钱包账户列表' }) + @Get('userWalletAccount/list') + @ApiResult({ type: [UserWalletAccount] }) + async getUserWalletAccountList(@GetToken() token: TokenInfo) { + return this.financeService.getUserWalletAccountList(token.id); + } + + @ApiOperation({ summary: '删除用户钱包账户' }) + @Delete('userWalletAccount/delete/:id') + @ApiResult({ type: String }) + async deleteUserWalletAccount( + @GetToken() token: TokenInfo, + @Param('id') id: string, + ) { + return await this.financeService.deleteUserWalletAccount(token.id, id); + } + // --------- userWalletAccount END --------- + + // --------- userWalletRecord STR --------- + @ApiOperation({ summary: '创建用户提现记录-提交提现' }) + @Post('userWalletRecord') + @ApiResult({ type: UserWalletAccount }) + async addUserWalletRecord( + @GetToken() token: TokenInfo, + @Body() body: CreateUserWalletRecordDto, + ) { + const { walletAccountId, balance } = body; + + const walletAccount = + await this.financeService.getUserWalletAccountById(walletAccountId); + + if (!walletAccount) + throw new AppHttpException(ErrHttpBack.wallet_account_no_had); + + // 获取余额 + const userWallet = await this.financeService.getUserWalletByUserId( + new ObjectId(token.id), + ); + + // 获取待提现金额 + const waitCount = await this.financeService.getDoingWalletRecordCount( + token.id, + ); + + // 计算可用余额 + const availableBalance = + parseFloat(userWallet.balance.toString()) - waitCount; + + if (!userWallet || availableBalance < balance) + throw new AppHttpException(ErrHttpBack.wallet_balance_no_enough); + + const res = await this.financeService.createUserWalletRecord( + token.id, + walletAccount, + { + type: UserWalletRecordType.WITHDRAW, + balance, + status: UserWalletRecordStatus.WAIT, + }, + ); + + return res; + } + + @ApiOperation({ summary: '获取用户账户记录列表' }) + @Get('userWalletRecord/list') + @ApiResult({ type: [UserWalletAccount] }) + async getUserWalletRecordList( + @GetToken() token: TokenInfo, + @Query() query: GetUserWalletRecordListDto, + ) { + return this.financeService.getUserWalletRecordList(token.id, query); + } + + @ApiOperation({ summary: '获取用户钱包信息' }) + @Get('userWallet/info') + @ApiResult({ type: UserWallet }) + async getUserWalletInfo(@GetToken() token: TokenInfo) { + return this.financeService.getUserWalletByUserId(new ObjectId(token.id)); + } + // --------- userWalletRecord END --------- +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.module.ts new file mode 100644 index 000000000..41cd87bdd --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.module.ts @@ -0,0 +1,35 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-03-25 09:43:22 + * @LastEditors: nevin + * @Description: 用户财产模块 + */ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { FinanceService } from './finance.service'; +import { FinanceController } from './finance.controller'; +import { + UserWalletAccount, + UserWalletAccountSchema, +} from 'src/db/schema/userWalletAccount.shema'; +import { + UserWalletRecord, + UserWalletRecordSchema, +} from 'src/db/schema/userWalletRecord.shema'; +import { AdminFinanceController } from './adminFinance.controller'; +import { UserWallet, UserWalletSchema } from 'src/db/schema/userWallet.shema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: UserWalletAccount.name, schema: UserWalletAccountSchema }, + { name: UserWalletRecord.name, schema: UserWalletRecordSchema }, + { name: UserWallet.name, schema: UserWalletSchema }, + ]), + ], + controllers: [FinanceController, AdminFinanceController], + providers: [FinanceService], + exports: [FinanceService], +}) +export class FinanceModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.service.ts new file mode 100644 index 000000000..eb3f216fb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/finance/finance.service.ts @@ -0,0 +1,290 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 14:05:49 + * @LastEditors: nevin + * @Description: + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { paginateModel } from 'src/common/paginate/create-pagination'; +import { UserWalletAccount } from 'src/db/schema/userWalletAccount.shema'; +import { + UserWalletRecord, + UserWalletRecordStatus, + UserWalletRecordType, +} from 'src/db/schema/userWalletRecord.shema'; +import { ErrHttpBack } from 'src/filters/http-exception.back-code'; +import { AppHttpException } from 'src/filters/http-exception.filter'; +import { RedisService } from 'src/lib/redis/redis.service'; +import { AlicloudSmsService } from 'src/lib/sms/alicloud-sms.service'; +import { getRandomString } from 'src/util'; +import { + GetUserWalletRecordListByAdminDto, + GetUserWalletRecordListDto, +} from './dto/userWalletRecord.dto'; +import { UserWallet } from 'src/db/schema/userWallet.shema'; +import { ObjectId } from 'mongodb'; +@Injectable() +export class FinanceService { + constructor( + private readonly redisService: RedisService, + private readonly alicloudSmsService: AlicloudSmsService, + @InjectModel(UserWallet.name) + private readonly userWalletModel: Model, + @InjectModel(UserWalletAccount.name) + private readonly userWalletAccountModel: Model, + @InjectModel(UserWalletRecord.name) + private readonly userWalletRecordModel: Model, + ) {} + + // ------- 用户的钱包账户 START --------- + /** + * 获取用户钱包账户 + * @param userId + * @returns + */ + async getUserWalletByUserId(userId: ObjectId): Promise { + const account = await this.userWalletModel.findOne({ userId }); + if (account) return account; + + return await this.userWalletModel.create({ + userId, + }); + } + + /** + * 更新用户钱包账户的余额 + * @param userWallet + * @param balance + * @returns + */ + async updateUserWalletBalance( + userId: ObjectId, + balance: number, + ): Promise { + const userWallet = await this.getUserWalletByUserId(userId); + const res = await this.userWalletModel.updateOne( + { userId: userWallet.userId }, + { + $inc: { + balance, + }, + }, + ); + + return res.modifiedCount > 0; + } + + // ------- 用户的钱包账户 END --------- + + // --------- userWalletAccount STR --------- + /** + * 发送手机号验证码-创建用户钱包账户 + * @param phone + */ + async postCreateUserWalletAccountCode(phone: string) { + const cacheKey = `CreateUserWalletAccount:${phone}`; + let code = await this.redisService.get(cacheKey); + if (code) throw new AppHttpException(ErrHttpBack.err_user_code_had); + + code = getRandomString(6, true); + const res = await this.alicloudSmsService.sendLoginSms(phone, code); + + if (process.env.NODE_ENV === 'production') { + if (!res) throw new AppHttpException(ErrHttpBack.err_user_code_send_fail); + } + + this.redisService.setKey(cacheKey, code, 60 * 5); + return process.env.NODE_ENV === 'production' ? res : code; + } + + /** + * 创建用户钱包账户 + * @param user + * @param data + * @returns + */ + async createUserWalletAccount( + userId: string, + data: Partial, + ) { + return await this.userWalletAccountModel.create({ + ...data, + isDef: false, + userId, + }); + } + + // 根据ID获取用户钱包账户 + async getUserWalletAccountById(id: string) { + return await this.userWalletAccountModel.findOne({ _id: id }); + } + // 获取用户钱包账户列表 + async getUserWalletAccountList(userId: string) { + return await this.userWalletAccountModel.find({ userId }); + } + + // 删除用户钱包账户 + async deleteUserWalletAccount(userId: string, id: string): Promise { + const res = await this.userWalletAccountModel.deleteOne({ + userId, + _id: id, + }); + return res.deletedCount > 0; + } + // --------- userWalletAccount END --------- + + // --------- userWalletRecord STR --------- + // 创建用户钱包记录 + async createUserWalletRecord( + userId: string, + account: UserWalletAccount, + data: { + dataId?: string; // 关联数据的ID + type: UserWalletRecordType; + balance: number; + status: UserWalletRecordStatus; + des?: string; + }, + ) { + return await this.userWalletRecordModel.create({ + ...data, + userId, + account: account.id, + }); + } + + // 分页获取记录列表 + async getUserWalletRecordList( + userId: string, + query: GetUserWalletRecordListDto, + ) { + const { page, pageSize, type, time } = query; + const filter: RootFilterQuery = { + userId, + ...(type && { type }), + ...(time && { + createTime: { + $gte: new Date(time[0]), + $lte: new Date(time[1]), + }, + }), + }; + + return paginateModel( + this.userWalletRecordModel, + { page, pageSize }, + filter, + 'account', + { _id: -1 }, + ); + } + + /** + * 获取列表 + * @param query + * @returns + */ + async getWalletRecordList(query: GetUserWalletRecordListByAdminDto) { + const { page, pageSize, type, userId, status, time } = query; + const filter: RootFilterQuery = { + ...(type && { type }), + ...(userId && { userId }), + ...(status !== undefined && { status }), + ...(time && { + payTime: { + $gte: new Date(time[0]), + $lte: new Date(time[1]), + }, + }), + }; + + return paginateModel( + this.userWalletRecordModel, + { page, pageSize }, + filter, + 'account', + { _id: -1 }, + ); + } + + /** + * 提交发布奖励 + * @param id + * @param data + * @returns + */ + async submitUserWalletRecord( + id: string, + data: { + imgUrl?: string; // 反馈截图 + des?: string; + }, + ): Promise { + const { balance, userId } = await this.userWalletRecordModel.findOne({ + _id: id, + }); + + const res = await this.userWalletRecordModel.updateOne( + { _id: id }, + { + ...data, + status: UserWalletRecordStatus.SUCCESS, + payTime: new Date(), + }, + ); + + // 减少余额 + this.updateUserWalletBalance(new ObjectId(userId), -balance); + + return res.modifiedCount > 0; + } + + /** + * 拒绝发布奖励 + * @param id + * @param data + * @returns + */ + async rejectUserWalletRecord( + id: string, + data: { + imgUrl?: string; // 反馈截图 + des?: string; + }, + ): Promise { + const res = await this.userWalletRecordModel.updateOne( + { _id: id }, + { + ...data, + status: UserWalletRecordStatus.FAIL, + }, + ); + + return res.modifiedCount > 0; + } + + // --------- userWalletRecord END --------- + + // 获取提现中的钱数总和 + async getDoingWalletRecordCount(userId: string) { + const result = await this.userWalletRecordModel.aggregate([ + { + $match: { + userId, + status: UserWalletRecordStatus.WAIT, + }, + }, + { + $group: { + _id: null, + totalBalance: { $sum: '$balance' }, + }, + }, + ]); + + return result.length > 0 ? result[0].totalBalance : 0; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminBanner.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminBanner.controller.ts new file mode 100644 index 000000000..84a735501 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminBanner.controller.ts @@ -0,0 +1,130 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-03-03 18:45:45 + * @LastEditors: nevin + * @Description: banner Banner + */ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { BannerService } from './banner.service'; +import { AppHttpException } from 'src/filters/http-exception.filter'; +import { ErrHttpBack } from 'src/filters/http-exception.back-code'; +import { TableDto } from 'src/global/dto/table.dto'; +import { + ActionBannerDto, + BannerIdDto, + GetBannerListDto, +} from './dto/banner.dto'; +import { ONOFF } from 'src/global/enum/all.enum'; +import { Banner } from 'src/db/schema/banner.schema'; +import { Manager } from 'src/auth/manager.guard'; + +@Manager() +@Controller('admin/banner') +export class AdminBannerController { + constructor(private readonly bannerService: BannerService) {} + + @ApiOperation({ + description: '创建', + summary: '创建', + }) + @Post() + async create(@Body(new ParamsValidationPipe()) body: ActionBannerDto) { + const newData = new Banner(); + newData.dataId = body.dataId; + newData.desc = body.desc; + newData.url = body.url; + newData.imgUrl = body.imgUrl; + newData.tag = body.tag; + newData.isPublish = ONOFF.ON; + + const res = await this.bannerService.create(newData); + return res; + } + + @ApiOperation({ + description: '获取信息', + summary: '获取信息', + }) + @Get('info/:id') + async getBannerInfoById( + @Param(new ParamsValidationPipe()) param: BannerIdDto, + ) { + const res = await this.bannerService.getBannerInfo(param.id); + return res; + } + + // 获取列表 + @Get('list/:pageNo/:pageSize') + getMineBannerList( + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: GetBannerListDto, + ) { + return this.bannerService.getBannerList(param, query); + } + + @ApiOperation({ + description: '更新', + summary: '更新', + }) + @Put('info/:id') + async update( + @Param(new ParamsValidationPipe()) param: BannerIdDto, + @Body(new ParamsValidationPipe()) body: ActionBannerDto, + ) { + const oldData = await this.bannerService.getBannerInfo(param.id); + if (!oldData) throw new AppHttpException(ErrHttpBack.fail); + + oldData.dataId = body.dataId; + oldData.desc = body.desc; + oldData.url = body.url; + oldData.imgUrl = body.imgUrl; + oldData.tag = body.tag; + + const res = await this.bannerService.updateBannerInfo(param.id, oldData); + return res; + } + + @ApiOperation({ + description: '更新发布状态', + summary: '更新发布状态', + }) + @Put('publish/:id') + async updateBannerPublishStatus( + @Param(new ParamsValidationPipe()) param: BannerIdDto, + @Body(new ParamsValidationPipe()) body: any, + ) { + const bannerInfo = await this.bannerService.getBannerInfo(param.id); + if (!bannerInfo) throw new AppHttpException(ErrHttpBack.fail); + + const res = await this.bannerService.updateBannerPublish( + param.id, + body.checkStatus, + ); + return res; + } + + @ApiOperation({ + description: '删除', + summary: '删除', + }) + @Delete(':id') + async delete(@Param(new ParamsValidationPipe()) param: BannerIdDto) { + const bannerInfo = await this.bannerService.getBannerInfo(param.id); + if (!bannerInfo) throw new AppHttpException(ErrHttpBack.fail); + + const res = await this.bannerService.deleteBanner(param.id); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminGzh.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminGzh.controller.ts new file mode 100644 index 000000000..0429b84fe --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/adminGzh.controller.ts @@ -0,0 +1,40 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-04-14 17:11:23 + * @LastEditors: nevin + * @Description: adminGzh AdminGzh + */ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { Manager } from 'src/auth/manager.guard'; +import { CreateGzhMenuDto } from './dto/gzh.dto'; +import { PlatAuthWxGzhService } from 'src/lib/platAuth/wxGzh.service'; + +@Manager() +@Controller('admin/gzh') +export class AdminGzhController { + constructor(private readonly platAuthWxGzhService: PlatAuthWxGzhService) {} + + @ApiOperation({ + description: 'body是JSON对象', + summary: '创建菜单', + }) + @Post('menu') + async createMenu(@Body(new ParamsValidationPipe()) body: CreateGzhMenuDto) { + const menuData = JSON.parse(body.menuStr); + const res = await this.platAuthWxGzhService.createWxGzhMenu(menuData); + return res; + } + + @ApiOperation({ + description: '获取菜单', + summary: '获取菜单', + }) + @Get('menu') + async getMenu() { + const res = await this.platAuthWxGzhService.getMenu(); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.controller.ts new file mode 100644 index 000000000..3d5eae508 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.controller.ts @@ -0,0 +1,39 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-03-03 18:45:45 + * @LastEditors: nevin + * @Description: banner Banner + */ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { BannerService } from './banner.service'; +import { AppGetBannerListDto, BannerIdDto } from './dto/banner.dto'; + +@Controller('banner') +export class BannerController { + constructor(private readonly bannerService: BannerService) {} + @ApiOperation({ + description: '获取信息', + summary: '获取信息', + }) + @Get('info/:id') + async getBannerInfoById( + @Param(new ParamsValidationPipe()) param: BannerIdDto, + ) { + const res = await this.bannerService.getBannerInfo(param.id); + return res; + } + + @ApiOperation({ + description: '获取列表', + summary: '获取列表', + }) + @Get('list') + getMineBannerList( + @Query(new ParamsValidationPipe()) query: AppGetBannerListDto, + ) { + return this.bannerService.getBannerAll(query.tag); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.service.ts new file mode 100644 index 000000000..eb5bebac0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/banner.service.ts @@ -0,0 +1,101 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2025-03-03 18:41:40 + * @LastEditors: nevin + * @Description: + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { Banner } from 'src/db/schema/banner.schema'; +import { ResponseUtil } from 'src/global/class/correctResponse.class'; +import { TableUtil } from 'src/global/class/tableUtli.class'; +import { TableDto } from 'src/global/dto/table.dto'; +import { ONOFF } from 'src/global/enum/all.enum'; + +@Injectable() +export class BannerService { + constructor( + @InjectModel(Banner.name) + private readonly bannerModel: Model, + ) {} + + // 创建 + async create(data: Banner) { + return await this.bannerModel.create(data); + } + + // 获取 + async getBannerInfo(id: string): Promise { + const res = await this.bannerModel.findOne({ _id: id }); + return res; + } + + /** + * 获取列表 + * @param pageInfo + * @param query + * @returns + */ + async getBannerList(pageInfo: TableDto, query: any) { + const { skip, take } = TableUtil.GetSqlPaging(pageInfo); + const filter: RootFilterQuery = { + ...(query.createTimeArray && { + createTime: { + $gte: query.createTimeArray[0], + $lte: query.createTimeArray[1], + }, + }), + }; + const tatal = await this.bannerModel.countDocuments(filter); + const data = await this.bannerModel + .find(filter) + .sort({ createTime: -1 }) + .skip(skip) + .limit(take); + + return ResponseUtil.GetCorrectResponse( + pageInfo.pageNo, + pageInfo.pageSize, + tatal, + data, + ); + } + + /** + * 获取全部 + * @param tag + * @returns + */ + async getBannerAll(tag?: string) { + const filter: RootFilterQuery = { + ...(tag && { + tag, + }), + }; + const data = await this.bannerModel.find(filter).sort({ createTime: -1 }); + return data; + } + + // 更新信息 + async updateBannerInfo(id: string, data: Banner): Promise { + const res = await this.bannerModel.updateOne({ _id: id }, data); + return res.modifiedCount > 0; + } + + // 更新发布状态 + async updateBannerPublish(id: string, isPublish: ONOFF): Promise { + const res = await this.bannerModel.updateOne( + { _id: id }, + { $set: { isPublish } }, + ); + return res.modifiedCount > 0; + } + + // 删除 + async deleteBanner(id: string): Promise { + const res = await this.bannerModel.deleteOne({ _id: id }); + return res.deletedCount > 0; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.controller.ts new file mode 100644 index 000000000..c34ca5550 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.controller.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-03-03 18:45:45 + * @LastEditors: nevin + * @Description: Cfg cfg + */ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { CfgService } from './cfg.service'; +import { CfgKeyDto } from './dto/cfg.dto'; + +@Controller('cfg') +export class CfgController { + constructor(private readonly bannerService: CfgService) {} + + @ApiOperation({ + description: '获取信息', + summary: '获取信息', + }) + @Get('info/:key') + async getInfoByKey(@Param(new ParamsValidationPipe()) param: CfgKeyDto) { + const res = await this.bannerService.getInfoByKey(param.key); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.service.ts new file mode 100644 index 000000000..374694d54 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfg.service.ts @@ -0,0 +1,94 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2025-03-03 18:41:40 + * @LastEditors: nevin + * @Description: Cfg cfg + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { Cfg } from 'src/db/schema/cfg.schema'; +import { ResponseUtil } from 'src/global/class/correctResponse.class'; +import { TableUtil } from 'src/global/class/tableUtli.class'; +import { TableDto } from 'src/global/dto/table.dto'; +import { ONOFF } from 'src/global/enum/all.enum'; + +@Injectable() +export class CfgService { + constructor( + @InjectModel(Cfg.name) + private readonly cfgModel: Model, + ) {} + + // 创建或者更新 + async create(data: Partial) { + const { key, ...restData } = data; + const options = { + upsert: true, + new: true, + setDefaultsOnInsert: true, + }; + return await this.cfgModel.findOneAndUpdate( + { key }, + { $set: restData }, + options, + ); + } + + // 获取 + async getInfoById(id: string): Promise { + const res = await this.cfgModel.findOne({ _id: id }); + return res; + } + + // 获取 + async getInfoByKey(key: string): Promise { + const res = await this.cfgModel.findOne({ key }); + return res; + } + + /** + * 获取列表 + * @param pageInfo + * @returns + */ + async getCfgList(pageInfo: TableDto) { + const { skip, take } = TableUtil.GetSqlPaging(pageInfo); + const filter: RootFilterQuery = {}; + const tatal = await this.cfgModel.countDocuments(filter); + const data = await this.cfgModel + .find(filter) + .sort({ createTime: -1 }) + .skip(skip) + .limit(take); + + return ResponseUtil.GetCorrectResponse( + pageInfo.pageNo, + pageInfo.pageSize, + tatal, + data, + ); + } + + // 更新信息 + async updateValue(id: string, data: any): Promise { + const res = await this.cfgModel.updateOne({ _id: id }, data); + return res.modifiedCount > 0; + } + + // 更新状态 + async updateStatus(id: string, status: ONOFF): Promise { + const res = await this.cfgModel.updateOne( + { _id: id }, + { $set: { status } }, + ); + return res.modifiedCount > 0; + } + + // 删除 + async del(key: string): Promise { + const res = await this.cfgModel.deleteMany({ key }); + return res.deletedCount > 0; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfgAdmin.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfgAdmin.controller.ts new file mode 100644 index 000000000..777cbd5c1 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/cfgAdmin.controller.ts @@ -0,0 +1,59 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-03-03 18:45:45 + * @LastEditors: nevin + * @Description: Cfg cfg + */ +import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { CfgService } from './cfg.service'; +import { TableDto } from 'src/global/dto/table.dto'; +import { CfgIdDto, CfgKeyDto, CreateCfgDto } from './dto/cfg.dto'; +import { Manager } from 'src/auth/manager.guard'; + +@Manager() +@Controller('admin/cfg') +export class AdminCfgController { + constructor(private readonly cfgService: CfgService) {} + + @ApiOperation({ + description: '创建', + summary: '创建', + }) + @Post() + async create(@Body(new ParamsValidationPipe()) body: CreateCfgDto) { + const res = await this.cfgService.create(body); + return res; + } + + @ApiOperation({ + description: '获取信息', + summary: '获取信息', + }) + @Get('info/:id') + async getInfoById(@Param(new ParamsValidationPipe()) param: CfgIdDto) { + const res = await this.cfgService.getInfoById(param.id); + return res; + } + + @ApiOperation({ + description: '获取列表', + summary: '获取列表', + }) + @Get('list/:pageNo/:pageSize') + getList(@Param(new ParamsValidationPipe()) param: TableDto) { + return this.cfgService.getCfgList(param); + } + + @ApiOperation({ + description: '删除', + summary: '删除', + }) + @Delete(':key') + async del(@Param(new ParamsValidationPipe()) param: CfgKeyDto) { + const res = await this.cfgService.del(param.key); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/banner.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/banner.dto.ts new file mode 100644 index 000000000..abc4e38dc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/banner.dto.ts @@ -0,0 +1,73 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-03-03 19:00:31 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { Banner, BannerTag } from 'src/db/schema/banner.schema'; +import { ONOFF } from 'src/global/enum/all.enum'; + +export class ActionBannerDto implements Partial { + @ApiProperty({ required: false }) + @IsString({ message: '数据ID' }) + @IsOptional() + @Expose() + readonly dataId?: string; + + @ApiProperty({ required: false }) + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string; + + @ApiProperty({ required: false }) + @IsString({ message: '链接' }) + @IsOptional() + @Expose() + readonly url?: string; + + @ApiProperty({ title: '图片链接', required: true }) + @IsString({ message: '图片链接' }) + @Expose() + readonly imgUrl: string; + + @ApiProperty({ title: '标识', required: true }) + @IsEnum(BannerTag, { message: '标识' }) + @Expose() + readonly tag: BannerTag; +} + +export class BannerIdDto { + @ApiProperty({ title: 'ID', required: true }) + @IsString({ message: 'ID' }) + @Expose() + id: string; +} + +export class GetBannerListDto { + @ApiProperty({ title: 'ID', required: false }) + @IsString({ message: 'ID' }) + @IsOptional() + @Expose() + readonly id?: string; +} + +export class AppGetBannerListDto { + @ApiProperty({ title: 'tag', required: false }) + @IsEnum(BannerTag, { message: 'tag' }) + @IsOptional() + @Expose() + readonly tag?: BannerTag; +} + +export class UpdateBannerPublishDto { + @ApiProperty({ title: '是否发布', required: true }) + @IsEnum(ONOFF, { message: '是否发布' }) + @Type(() => Number) + @Expose() + readonly isPublish: ONOFF; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/cfg.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/cfg.dto.ts new file mode 100644 index 000000000..f8fcfe66f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/cfg.dto.ts @@ -0,0 +1,48 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-03-03 19:00:31 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsObject, IsString } from 'class-validator'; + +export class CreateCfgDto { + @ApiProperty({ required: true }) + @IsString({ message: 'key' }) + @Expose() + readonly key: string; + + @ApiProperty({ required: true }) + @IsString({ message: '标题必须填写' }) + @Expose() + readonly title: string; + + @ApiProperty({ required: true }) + @IsObject({ message: '内容' }) + @Expose() + readonly content: any; +} + +export class UpdateCfgDto { + @ApiProperty({ required: true }) + @IsString({ message: '内容' }) + @Expose() + readonly content: string; +} + +export class CfgIdDto { + @ApiProperty({ title: 'ID', required: true }) + @IsString({ message: 'ID' }) + @Expose() + id: string; +} + +export class CfgKeyDto { + @ApiProperty({ title: 'key', required: true }) + @IsString({ message: 'key' }) + @Expose() + key: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/gzh.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/gzh.dto.ts new file mode 100644 index 000000000..a8b7f4dfc --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/dto/gzh.dto.ts @@ -0,0 +1,17 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 20:12:31 + * @LastEditTime: 2025-04-14 16:36:06 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString } from 'class-validator'; + +export class CreateGzhMenuDto { + @ApiProperty({ title: '菜单JSON字符', required: true }) + @IsString({ message: '菜单JSON字符' }) + @Expose() + readonly menuStr: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/operate.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/operate.module.ts new file mode 100644 index 000000000..5b0ee8979 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/operate/operate.module.ts @@ -0,0 +1,39 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:07 + * @LastEditTime: 2025-04-14 16:41:35 + * @LastEditors: nevin + * @Description: 运营模块 + */ +import { Global, Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { BannerService } from './banner.service'; +import { BannerController } from './banner.controller'; +import { Banner, BannerSchema } from 'src/db/schema/banner.schema'; +import { AdminBannerController } from './adminBanner.controller'; +import { PlatAuthModule } from 'src/lib/platAuth/platAuth.module'; +import { AdminGzhController } from './adminGzh.controller'; +import { Cfg, CfgSchema } from 'src/db/schema/cfg.schema'; +import { CfgController } from './cfg.controller'; +import { CfgService } from './cfg.service'; +import { AdminCfgController } from './cfgAdmin.controller'; + +@Global() +@Module({ + imports: [ + PlatAuthModule, + MongooseModule.forFeature([ + { name: Banner.name, schema: BannerSchema }, + { name: Cfg.name, schema: CfgSchema }, + ]), + ], + providers: [BannerService, CfgService], + controllers: [ + BannerController, + AdminBannerController, + AdminGzhController, + CfgController, + AdminCfgController, + ], +}) +export class OperateModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/adminQa.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/adminQa.controller.ts new file mode 100644 index 000000000..4fc75146f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/adminQa.controller.ts @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-04-14 17:45:14 + * @LastEditors: nevin + * @Description: QA + */ +import { Controller, Post } from '@nestjs/common'; +import { FeedbackService } from './feedback.service'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { GetToken } from 'src/auth/auth.guard'; +import { Manager } from 'src/auth/manager.guard'; + +@Manager() +@Controller('admin/qa') +export class AdminQaController { + constructor(private readonly feedbackService: FeedbackService) {} + + // 创建QA + @Post() + async createQa(@GetToken() token: TokenInfo) { + return 1; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/dto/feedback.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/dto/feedback.dto.ts new file mode 100644 index 000000000..8c91f50ad --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/dto/feedback.dto.ts @@ -0,0 +1,90 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: 反馈 + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + Validate, +} from 'class-validator'; +import { FeedbackType } from 'src/db/schema/feedback.schema'; + +export class CreateFeedBackDto { + @ApiProperty({ title: '内容', required: true }) + @IsString({ message: '内容' }) + @Expose() + readonly content: string; + + @ApiProperty({ + title: '类型', + required: false, + description: '', + }) + @IsEnum(FeedbackType, { message: '类型' }) + @IsOptional() + @Expose() + readonly type?: FeedbackType; + + @ApiProperty({ type: [String], description: '标识数组' }) + @IsArray() + @IsString({ each: true, message: '字符串数组' }) + @IsOptional() + @Expose() + readonly tagList?: string[]; + + @ApiProperty({ type: [String], description: '文件链接数组' }) + @IsArray() + @IsString({ each: true, message: '文件链接' }) + @IsOptional() + @Expose() + readonly fileUrlList?: string[]; +} + +export class GetFeedbackListDto { + @ApiProperty({ + title: '起始时间', + required: false, + type: [String], + description: '时间范围数组,格式为[startDate, endDate],格式YYYY-MM-DD', + example: ['2023-01-01', '2023-12-31'], + }) + @Type(() => Date) // 将传入的字符串自动转换为Date对象 + @IsArray({ message: '时间范围必须为数组' }) + @ArrayMinSize(2, { message: '时间范围需要两个元素' }) + @ArrayMaxSize(2, { message: '时间范围不能超过两个元素' }) + @Validate( + (value: Date[]) => { + return value; + }, + { + each: true, + message: '必须为有效的日期格式', + }, + ) + @IsOptional() + @Expose() + readonly time?: [Date, Date]; + + @ApiProperty({ type: String }) + @IsNotEmpty({ message: '用户ID不能为空' }) + @IsString() + @IsOptional() + @Expose() + userId?: string; + + @ApiProperty({ enum: FeedbackType }) + @IsEnum(FeedbackType) + @IsOptional() + @Expose() + type?: FeedbackType; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.controller.ts new file mode 100644 index 000000000..74e4b1fd8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.controller.ts @@ -0,0 +1,72 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 反馈 + */ +import { Body, Controller, Post, Sse } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { FeedbackService } from './feedback.service'; +import { CreateFeedBackDto } from './dto/feedback.dto'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { UserService } from 'src/user/user.service'; +import { interval, map, Observable } from 'rxjs'; +import { GetToken } from 'src/auth/auth.guard'; +import { Feedback } from 'src/db/schema/feedback.schema'; + +@ApiTags('反馈') +@Controller('feedback') +export class FeedbackController { + constructor( + private readonly feedbackService: FeedbackService, + private readonly userService: UserService, + ) {} + + @ApiOperation({ + description: '提交反馈', + summary: '提交反馈', + }) + @Post() + async createFeedback( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: CreateFeedBackDto, + ) { + const { fileUrlList, content, type, tagList } = body; + const userInfo = await this.userService.getUserInfoById(token.id); + const newData = new Feedback(); + newData.content = content; + newData.userId = userInfo.id; + newData.userName = userInfo.name; + newData.fileUrlList = fileUrlList; // TODO: 去除临时目录 + newData.type = type; + newData.tagList = tagList; + + const res = await this.feedbackService.createFeedback(newData); + return res; + } + + @ApiOperation({ + description: 'sse接口测试', + summary: 'sse接口测试', + }) + @Post('sse') + @Sse('sse') + sse2(): Observable { + const text = [ + '这是第一句话', + '这是第二句话', + '这是第三句话', + '这是第四句话', + ]; + console.log(text); + return interval(1000).pipe( + map((i) => { + return { + data: text[i], + }; + }), + ); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.service.ts new file mode 100644 index 000000000..63f83b44b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedback.service.ts @@ -0,0 +1,72 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { Feedback } from 'src/db/schema/feedback.schema'; +import { ResponseUtil } from 'src/global/class/correctResponse.class'; +import { TableUtil } from 'src/global/class/tableUtli.class'; +import { TableDto } from 'src/global/dto/table.dto'; +import { GetFeedbackListDto } from './dto/feedback.dto'; + +@Injectable() +export class FeedbackService { + constructor( + @InjectModel(Feedback.name) + private readonly feedbackModel: Model, + ) {} + + async createFeedback(newData: Feedback) { + return await this.feedbackModel.create(newData); + } + + /** + * 获取列表 + * @param pageInfo + * @param query + * @returns + */ + async getFeedbackList(pageInfo: TableDto, query: GetFeedbackListDto) { + const { skip, take } = TableUtil.GetSqlPaging(pageInfo); + const filter: RootFilterQuery = { + ...(query.time && { + createTime: { + $gte: query.time[0], + $lte: query.time[1], + }, + }), + ...(query.userId && { userId: query.userId }), + ...(query.type && { type: query.type }), + }; + const tatal = await this.feedbackModel.countDocuments(filter); + const data = await this.feedbackModel + .find(filter) + .sort({ createTime: -1 }) + .skip(skip) + .limit(take); + + return ResponseUtil.GetCorrectResponse( + pageInfo.pageNo, + pageInfo.pageSize, + tatal, + data, + ); + } + + // 根据ID获取信息 + async getFeedbackInfo(id: string) { + const data = await this.feedbackModel.findOne({ _id: id }); + return data; + } + + // 根据ID删除 + async delFeedback(id: string) { + const data = await this.feedbackModel.deleteOne({ _id: id }); + return data; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedbackAdmin.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedbackAdmin.controller.ts new file mode 100644 index 000000000..35562043b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/feedbackAdmin.controller.ts @@ -0,0 +1,57 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 反馈 + */ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { FeedbackService } from './feedback.service'; +import { UserService } from 'src/user/user.service'; +import { Manager } from 'src/auth/manager.guard'; +import { TableDto } from 'src/global/dto/table.dto'; +import { GetFeedbackListDto } from './dto/feedback.dto'; + +@Manager() +@Controller('admin/feedback') +export class FeedbackAdminController { + constructor( + private readonly feedbackService: FeedbackService, + private readonly userService: UserService, + ) {} + + @ApiOperation({ + description: '反馈列表', + summary: '反馈列表', + }) + @Get('list/:pageNo/:pageSize') + async getList( + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: GetFeedbackListDto, + ) { + const res = await this.feedbackService.getFeedbackList(param, query); + return res; + } + + @ApiOperation({ + description: '反馈详情', + summary: '反馈详情', + }) + @Get('info/:id') + async getInfo(@Param('id') id: string) { + const res = await this.feedbackService.getFeedbackInfo(id); + return res; + } + + @ApiOperation({ + description: '删除反馈', + summary: '删除反馈', + }) + @Get('del/:id') + async delFeedback(@Param('id') id: string) { + const res = await this.feedbackService.delFeedback(id); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/other.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/other.module.ts new file mode 100644 index 000000000..44e6ad51c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/other.module.ts @@ -0,0 +1,29 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:07 + * @LastEditTime: 2025-04-14 19:22:41 + * @LastEditors: nevin + * @Description: 其他模块 + */ +import { Global, Module } from '@nestjs/common'; +import { FeedbackService } from './feedback.service'; +import { FeedbackController } from './feedback.controller'; +import { MongooseModule } from '@nestjs/mongoose'; +import { Feedback, FeedbackSchema } from 'src/db/schema/feedback.schema'; +import { QaService } from './qa.service'; +import { QaRecord, QaRecordSchema } from 'src/db/schema/qaRecord.schema'; +import { QaController } from './qa.controller'; +import { FeedbackAdminController } from './feedbackAdmin.controller'; + +@Global() +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: Feedback.name, schema: FeedbackSchema }, + { name: QaRecord.name, schema: QaRecordSchema }, + ]), + ], + providers: [FeedbackService, QaService], + controllers: [FeedbackController, QaController, FeedbackAdminController], +}) +export class OtherModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.controller.ts new file mode 100644 index 000000000..a3f069e5d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.controller.ts @@ -0,0 +1,34 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2025-04-14 19:21:01 + * @LastEditors: nevin + * @Description: QA + */ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { GetToken } from 'src/auth/auth.guard'; +import { TableDto } from 'src/global/dto/table.dto'; +import { QaService } from './qa.service'; + +@ApiTags('QA') +@Controller('qa') +export class QaController { + constructor(private readonly qaService: QaService) {} + + @ApiOperation({ + description: '获取列表', + summary: '获取列表', + }) + @Get('list/:pageNo/:pageSize') + async getPubRecordDraftsList( + @GetToken() token: TokenInfo, + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: any, + ) { + const res = await this.qaService.getQaRecordList(param, query); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.service.ts new file mode 100644 index 000000000..4781a0006 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/other/qa.service.ts @@ -0,0 +1,55 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2025-04-14 17:53:17 + * @LastEditors: nevin + * @Description: + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { QaRecord } from 'src/db/schema/qaRecord.schema'; +import { TableDto } from 'src/global/dto/table.dto'; + +@Injectable() +export class QaService { + constructor( + @InjectModel(QaRecord.name) + private readonly qaRecordModel: Model, + ) {} + + async createQaRecord(newData: Partial) { + return await this.qaRecordModel.create(newData); + } + + /** + * 获取记录列表 + * @param userId + * @param page + * @returns + */ + async getQaRecordList( + page: TableDto, + query: any, + ): Promise<{ + list: QaRecord[]; + totalCount: number; + }> { + const filters: RootFilterQuery = { + ...(query.type !== undefined && { type: query.type }), + }; + + const list = await this.qaRecordModel + .find(filters) + .skip((page.pageNo - 1) * page.pageSize) + .limit(page.pageSize) + .sort({ sort: -1 }); + + const totalCount = await this.qaRecordModel.countDocuments(filters); + + return { + list, + totalCount, + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.controller.ts new file mode 100644 index 000000000..7919d1737 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.controller.ts @@ -0,0 +1,108 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + AccessBackDto, + ArchiveAddByUtokenBodyDto, + ArchiveAddByUtokenQueryDto, +} from './dto/bilibili.dto'; +import { BilibiliService } from './bilibili.service'; +import { GetToken, Public } from 'src/auth/auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; + +@ApiTags('plat/bilibili - B站平台') +@Controller('plat/bilibili') +export class BilibiliController { + constructor(private readonly bilibiliService: BilibiliService) {} + + @ApiOperation({ summary: '获取页面的认证URL' }) + @Get('auth/url/:type') + async getAuthUrl( + @GetToken() token: TokenInfo, + @Param('type') type: 'h5' | 'pc', + ) { + const res = await this.bilibiliService.getAuthUrl(token.id, type); + return res; + } + + @ApiOperation({ summary: '获取AccessToken,并记录到用户,给平台回调用' }) + @Public() + @Get('auth/back/:userId') + async getAccessToken( + @Param('userId') userId: string, + @Query() query: AccessBackDto, + ) { + const res = await this.bilibiliService.setUserAccessToken({ + userId, + ...query, + }); + return res; + } + + @ApiOperation({ summary: '查询用户已授权权限列表' }) + @Get('account/scopes') + async getAccountScopes(@GetToken() token: TokenInfo) { + const accessToken = await this.bilibiliService.getUserAccessToken(token.id); + return this.bilibiliService.getAccountScopes(accessToken); + } + + @ApiOperation({ summary: '视频初始化' }) + @Post('video/init') + async videoInit(@GetToken() token: TokenInfo) { + const accessToken = await this.bilibiliService.getUserAccessToken(token.id); + return this.bilibiliService.videoInit(accessToken); + } + + @ApiOperation({ summary: '封面上传' }) + @UseInterceptors(FileInterceptor('file')) + @Post('cover/upload') + async coverUpload( + @GetToken() token: TokenInfo, + @UploadedFile() file: Express.Multer.File, + ) { + const accessToken = await this.bilibiliService.getUserAccessToken(token.id); + return this.bilibiliService.coverUpload(accessToken, file); + } + + @ApiOperation({ summary: '视频稿件提交' }) + @Post('archive/add-by-utoken') + async archiveAddByUtoken( + @Query() query: ArchiveAddByUtokenQueryDto, + @Body() body: ArchiveAddByUtokenBodyDto, + ) { + const data = { + ...body, + no_reprint: body.noReprint, + tag: body.tag.join(','), + }; + const { accessToken, uploadToken } = query; + return this.bilibiliService.archiveAddByUtoken( + accessToken, + uploadToken, + data, + ); + } + + @ApiOperation({ summary: '分区查询' }) + @Get('archive/type/list/:accessToken') + async archiveTypeList(@GetToken() token: TokenInfo) { + const accessToken = await this.bilibiliService.getUserAccessToken(token.id); + return this.bilibiliService.archiveTypeList(accessToken); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.module.ts new file mode 100644 index 000000000..c0cc0feb7 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.module.ts @@ -0,0 +1,24 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-27 17:36:19 + * @LastEditors: nevin + * @Description: bilibili Bilibili B站模块 + */ +import { Module } from '@nestjs/common'; +import { BilibiliController } from './bilibili.controller'; +import { BilibiliService } from './bilibili.service'; +import { ConfigModule } from '@nestjs/config'; +import bilibiliConfig from 'config/bilibili.config'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [bilibiliConfig], + }), + ], + controllers: [BilibiliController], + providers: [BilibiliService], + exports: [BilibiliService], +}) +export class BilibiliModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.service.ts new file mode 100644 index 000000000..d97f59327 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/bilibili.service.ts @@ -0,0 +1,355 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: b站 + */ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import { createHash, createHmac } from 'crypto'; +import { AccessToken, BClient, VideoUTypes } from './comment'; +import { ConfigService } from '@nestjs/config'; +import { getRandomString } from 'src/util'; +import { RedisService } from 'src/lib/redis/redis.service'; +import { getCurrentTimestamp } from 'src/util/time.util'; +@Injectable() +export class BilibiliService { + private clientId = ''; + private clientSecret = ''; + private clientName = ''; + private authBackUrl = ''; + constructor( + private readonly configService: ConfigService, + private readonly redisService: RedisService, + ) { + const cfg = this.configService.get('BILIBILI_CONFIG'); + this.clientId = cfg.clientId; + this.clientSecret = cfg.clientSecret; + this.clientName = cfg.clientName; + this.authBackUrl = cfg.authBackUrl; + } + + /** + * 获取用户的授权链接 + * @param userId + * @returns + */ + async getAuthUrl(userId: string, type: 'h5' | 'pc') { + const gourl = encodeURIComponent( + `${this.authBackUrl}/api/plat/bilibili/auth/back/${userId}`, + ); + + const state = getRandomString(8); + + this.redisService.setKey(`bilibili:state:${state}`, { userId }, 60 * 5); + + if (type === 'h5') + return `https://account.bilibili.com/h5/account-h5/auth/oauth?navhide=1&callback=close&gourl=${gourl}&client_id=${this.clientId}&state=${state}`; + + return `https://account.bilibili.com/pc/account-pc/auth/oauth?client_id=${this.clientId}&gourl=${gourl}&state=${state}`; + } + + /** + * 设置用户的授权Token + * @param data + * @returns + */ + async setUserAccessToken(data: { + code: string; + userId: string; + state: string; + }) { + const { code, userId, state } = data; + + const query = { + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'authorization_code', + code, + }; + + const stateData = await this.redisService.get(`bilibili:state:${state}`); + if (!stateData || stateData.userId !== userId) return false; + + try { + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + data: AccessToken; + }>('https://api.bilibili.com/x/account-oauth2/v1/token', null, { + params: query, + }); + + const accessTokenInfo = result.data.data; + + // 剩余有效秒数 + const expires = + accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + this.redisService.setKey( + `bilibili:accessToken:${userId}`, + accessTokenInfo, + expires, + ); + + return true; + } catch (error) { + console.log('Error during getUserAccessToken:', error); + return false; + } + } + + async getUserAccessToken(userId: string): Promise { + const res: AccessToken = await this.redisService.get( + `bilibili:accessToken:${userId}`, + ); + if (!res) return ''; + + // 剩余时间 + const overTime = res.expires_in - getCurrentTimestamp(); + + if (overTime < 60 * 60 && overTime > 0) { + // 刷新token + this.refreshAccessToken(userId, res.refresh_token); + } + + return res.access_token; + } + + /** + * 刷新授权Token + * @param userId + * @param refreshToken + * @returns + */ + async refreshAccessToken(userId: string, refreshToken: string) { + const query = { + client_id: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }; + + const url = `https://api.bilibili.com/x/account-oauth2/v1/refresh_token`; + try { + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + data: AccessToken; + }>(url, null, { params: query }); + + const accessTokenInfo = result.data.data; + + // 剩余有效秒数 + const expires = + accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + this.redisService.setKey( + `bilibili:accessToken:${userId}`, + accessTokenInfo, + expires, + ); + + return true; + } catch (error) { + console.log('Error during getAccessToken:', error); + return false; + } + } + + /** + * 查询用户已授权权限列表 + * @returns + */ + async getAccountScopes(accessToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/user/account/scopes`; + const result = await axios.get<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + data: { + openid: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + }; + }>(url, { + headers: this.generateBilibiliHeader({ + accessToken, + isForm: true, + }), + }); + + return result.data.data; + } + + /** + * 生成请求头 + * @param data + */ + private generateBilibiliHeader(data: { + accessToken: string; + body?: { [key: string]: any }; + isForm?: boolean; + }) { + const { accessToken, body, isForm } = data; + const xBiliContentMd5 = body + ? createHash('md5').update(JSON.stringify(body)).digest('hex') + : ''; + + const header = { + Accept: 'application/json', + 'Content-Type': isForm ? 'multipart/form-data' : 'application/json', // 或者 multipart/form-data + 'x-bili-content-md5': xBiliContentMd5, + 'x-bili-timestamp': Math.floor(Date.now() / 1000), + 'x-bili-signature-method': 'HMAC-SHA256', + 'x-bili-signature-nonce': uuidv4(), + 'x-bili-accesskeyid': this.clientId, + 'x-bili-signature-version': '1.0', + 'access-token': accessToken, // 需要在请求头中添加access-token + Authorization: '', + }; + + // 抽取带”x-bili-“前缀的自定义header,按字典排序拼接,构建完整的待签名字符串: + // 待签名字符串包含换行符\n + const headerStr = Object.keys(header) + .filter((key) => key.startsWith('x-bili-')) + .sort() + .map((key) => `${key}:${header[key]}\n`) + .join(''); + + // 使用 createHmac 正确创建签名 + const signature = createHmac('sha256', this.clientSecret) + .update(headerStr) + .digest('hex'); + + // 将签名加入 header + header.Authorization = signature; + + return header; + } + + /** + * 视频初始化 + * @param fileName + * @param utype // 1-单个小文件(不超过100M)。默认值为0 + * @returns + */ + async videoInit(fileName: string, utype: VideoUTypes = 0): Promise { + const body = { + name: fileName, // test.mp4 + utype, + }; + + const url = `https://member.bilibili.com/arcopen/fn/archive/video/init`; + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '7b753a287405461f5afa526a1f672094'; + data: { + upload_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + }; + }>(url, body); + return result.data.data.upload_token; + } + + /** + * 封面上传 + * @param accessToken + * @param file + * @returns + */ + async coverUpload( + accessToken: string, + file: Express.Multer.File, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/cover/upload`; + + const formData = new FormData(); + const blob = new Blob([file.buffer], { type: file.mimetype }); + formData.append('file', blob, file.originalname); + + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '7b753a287405461f5afa526a1f672094'; + data: { + url: string; // "https://archive.biliimg.com/bfs/..." + }; + }>(url, formData, { + headers: this.generateBilibiliHeader({ + accessToken: accessToken, + isForm: true, + }), + }); + return result.data.data.url; + } + + /** + * 视频稿件提交 + * @param accessToken + * @param uploadToken + * @param data + * @returns + */ + async archiveAddByUtoken( + accessToken: string, + uploadToken: string, + data: { + title: string; // 标题 + cover?: string; // 封面url + tid: number; // 分区ID,由获取分区信息接口得到 + no_reprint?: 0 | 1; // 是否允许转载 0-允许,1-不允许。默认0 + desc?: string; // 描述 + tag: string; // 标签, 多个标签用英文逗号分隔,总长度小于200 + copyright: 1 | 2; // 1-原创,2-转载(转载时source必填) + source?: string; // 如果copyright为转载,则此字段表示转载来源 + topic_id?: number; // 参加的话题ID,默认情况下不填写,需要填写和运营联系 + }, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/add-by-utoken`; + + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + data: { + resource_id: string; // 'BV17B4y1s7R1'; + }; + }>(url, data, { + headers: this.generateBilibiliHeader({ + accessToken, + }), + params: { + upload_token: uploadToken, + }, + }); + return result.data.data.resource_id; + } + + /** + * 分区查询 + * @param accessToken + * @returns + */ + async archiveTypeList(accessToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/archive/type/list`; + + const result = await axios.get<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '35f4a1e0d3765a92510f919d0b6721dd'; + data: any; + }>(url, { + headers: this.generateBilibiliHeader({ + accessToken, + }), + }); + return result.data.data; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/comment.ts new file mode 100644 index 000000000..cd38ddd09 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/comment.ts @@ -0,0 +1,18 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface BClient { + clientName: string; + clientId: string; + clientSecret: string; + authBackUrl: string; +} + +export interface AccessToken { + access_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number; // 1630220614; + refresh_token: string; // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/dto/bilibili.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/dto/bilibili.dto.ts new file mode 100644 index 000000000..c06a44575 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/bilibili/dto/bilibili.dto.ts @@ -0,0 +1,114 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:16:37 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { VideoUTypes } from '../comment'; + +export class AccessBackDto { + @Expose() + code: string; + + @Expose() + state: string; +} + +export class VideoInitDto { + @ApiProperty({ title: '文件名称(带格式)', required: true }) + @IsString({ message: '文件名称' }) + @Expose() + readonly fileName: string; + + @ApiProperty({ enum: VideoUTypes }) + @IsNotEmpty({ message: '文件类型不能为空' }) + @IsEnum(VideoUTypes) + @Type(() => Number) + @Expose() + readonly utype: VideoUTypes; +} + +export class ArchiveAddByUtokenQueryDto { + @ApiProperty({ title: '用户token', required: true }) + @IsString({ message: '用户token' }) + @Expose() + readonly accessToken: string; + + @ApiProperty({ title: '上传token', required: true }) + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string; +} + +export class ArchiveAddByUtokenBodyDto { + @ApiProperty({ title: '标题', required: true }) + @IsString({ message: '标题' }) + @Expose() + readonly title: string; + + @ApiProperty({ title: '封面url', required: false }) + @IsString({ message: '封面url' }) + @IsOptional() + @Expose() + readonly cover?: string; + + @ApiProperty({ title: '分区ID,由获取分区信息接口得到', required: true }) + @IsNumber({ allowNaN: false }, { message: '分区ID,由获取分区信息接口得到' }) + @Expose() + readonly tid: number; + + @ApiProperty({ + title: '是否允许转载 0-允许,1-不允许。默认0', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '是否允许转载 0-允许,1-不允许。默认0' }, + ) + @IsOptional() + @Expose() + readonly noReprint?: 0 | 1; + + @ApiProperty({ title: '描述', required: false }) + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string; + + @ApiProperty({ title: '标签', required: false }) + @IsArray({ message: '标签必须是字符串数组' }) + @IsString({ each: true, message: '描述' }) + @Expose() + readonly tag: string[]; + + @ApiProperty({ + title: '1-原创,2-转载(转载时source必填)', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '1-原创,2-转载(转载时source必填)' }, + ) + @Expose() + readonly copyright: 1 | 2; + + @ApiProperty({ + title: '如果copyright为转载,则此字段表示转载来源', + required: false, + }) + @IsString({ message: '如果copyright为转载,则此字段表示转载来源' }) + @IsOptional() + @Expose() + readonly source?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/comment.ts new file mode 100644 index 000000000..905e4f4f0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/comment.ts @@ -0,0 +1,15 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + + +export interface AccessToken { + access_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number; // 1630220614; + refresh_token: string; // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + token_type: string; + id_token: string; +} + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/dto/google.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/dto/google.dto.ts new file mode 100644 index 000000000..5966d6710 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/dto/google.dto.ts @@ -0,0 +1,126 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:16:37 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { VideoUTypes } from '../comment'; + +export class AccessBackDto { + @Expose() + code: string; + + @Expose() + state: string; +} + +export class VideoInitDto { + @ApiProperty({ title: '文件名称(带格式)', required: true }) + @IsString({ message: '文件名称' }) + @Expose() + readonly fileName: string; + + @ApiProperty({ enum: VideoUTypes }) + @IsNotEmpty({ message: '文件类型不能为空' }) + @IsEnum(VideoUTypes) + @Type(() => Number) + @Expose() + readonly utype: VideoUTypes; +} + +export class ArchiveAddByUtokenQueryDto { + @ApiProperty({ title: '用户token', required: true }) + @IsString({ message: '用户token' }) + @Expose() + readonly accessToken: string; + + @ApiProperty({ title: '上传token', required: true }) + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string; +} + +export class ArchiveAddByUtokenBodyDto { + @ApiProperty({ title: '标题', required: true }) + @IsString({ message: '标题' }) + @Expose() + readonly title: string; + + @ApiProperty({ title: '封面url', required: false }) + @IsString({ message: '封面url' }) + @IsOptional() + @Expose() + readonly cover?: string; + + @ApiProperty({ title: '分区ID,由获取分区信息接口得到', required: true }) + @IsNumber({ allowNaN: false }, { message: '分区ID,由获取分区信息接口得到' }) + @Expose() + readonly tid: number; + + @ApiProperty({ + title: '是否允许转载 0-允许,1-不允许。默认0', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '是否允许转载 0-允许,1-不允许。默认0' }, + ) + @IsOptional() + @Expose() + readonly noReprint?: 0 | 1; + + @ApiProperty({ title: '描述', required: false }) + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string; + + @ApiProperty({ title: '标签', required: false }) + @IsArray({ message: '标签必须是字符串数组' }) + @IsString({ each: true, message: '描述' }) + @Expose() + readonly tag: string[]; + + @ApiProperty({ + title: '1-原创,2-转载(转载时source必填)', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '1-原创,2-转载(转载时source必填)' }, + ) + @Expose() + readonly copyright: 1 | 2; + + @ApiProperty({ + title: '如果copyright为转载,则此字段表示转载来源', + required: false, + }) + @IsString({ message: '如果copyright为转载,则此字段表示转载来源' }) + @IsOptional() + @Expose() + readonly source?: string; +} + +export class GoogleLoginDto { + @ApiProperty({ description: 'Google客户端ID' }) + @IsString() + @Expose() + clientId: string; + + @ApiProperty({ description: 'Google认证凭证' }) + @IsString() + @Expose() + credential: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.controller.ts new file mode 100644 index 000000000..b6c0b9180 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.controller.ts @@ -0,0 +1,168 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UploadedFile, + UseInterceptors, + BadRequestException, + Render, + Res +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + AccessBackDto, + ArchiveAddByUtokenBodyDto, + ArchiveAddByUtokenQueryDto, + GoogleLoginDto +} from './dto/google.dto'; +import { GoogleService } from './google.service'; +import { GetToken, Public } from 'src/auth/auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { Response } from 'express'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { Account } from 'src/db/schema/account.schema'; +import { ApiResult } from 'src/common/decorators/api-result.decorator'; +import { ParamsValidationPipe } from 'src/validation.pipe'; + +@ApiTags('plat/google - Google账号登录') +@Controller('plat/google') +export class GoogleController { + constructor(private readonly googleService: GoogleService) {} + + @ApiOperation({ summary: '测试' }) + @Public() + @Get('test') + // @Render('google/index') + async getTest(@Res() res: Response) { + // const res = "success"; + const result = { message: "hello" }; + console.log(result); // 在控制台输出,确保数据正确 + // return result; + return res.render('google/index', result); + } + + + @ApiOperation({ summary: '获取用户登录授权URL' }) + @Public() + @Get('auth/code') + async getGooleAuthCode( + // @GetToken() token: TokenInfo, + @Param('type') type: 'h5' | 'pc', + @Query('platform') platform: string, + @Query('userEmail') userEmail?: string, + ) { + // const result = await this.googleService.getAuthCode(userEmail, platform, type); + const result = await this.googleService.getAuthCode_v2(platform, type); + console.log(result.data); + return result; + } + + @ApiOperation({ summary: 'Google登录' }) + @Public() + // @Get('auth/login') + @Post('auth/login') + @Public() + @ApiResult({ type: Account }) + async googleLogin( + @Body(new ParamsValidationPipe()) body: GoogleLoginDto, + ) { + // console.log(body.clientId, body.credential) + return this.googleService.googleLogin(body.clientId, body.credential); + } + + @ApiOperation({ summary: '获取AccessToken' }) + @Public() + @Get('auth/accessToken') + async getAccessToken( + @Query('code') code: string, + @Query('state') state: string, + // @Query() query: AccessBackDto, + // @Res() res: Response + ) { + + try { + // 使用授权码 (code) 交换访问令牌 + const systemToken = await this.googleService.setUserAccessToken_V2({ + code, + state + }); + console.log("+++++++++++++++++++++++"); + console.log(systemToken ); + console.log("+++++++++++++++++++++++"); + + // 成功获取令牌后,返回成功消息或视图 + // return res.redirect(301, 'https://www.example.com?name=JohnDoe&age=30'); + // return res.render('google/index', { message: '授权成功', userInfo: userInfo}) + return systemToken; + } catch (err) { + console.log('Error during access token exchange', err); + // return res.render('google/index', { message: '授权失败', error: err }); + return false + } + + // const result = await this.googleService.getAccessToken(code) + // return result; + + // const result = { message: "hello" }; + // return result; + // return res.render('google/index', { message: result}); + // const data = { + // ...body, + // code: body.code, + // state: body.state, + // }; + // const { accessToken, uploadToken } = query; + // return this.bilibiliService.archiveAddByUtoken( + // accessToken, + // uploadToken, + // data, + // ); + } + + + @ApiOperation({ summary: '刷新授权Token' }) + // @Public() + @Get('auth/refreshAccessToken') + async refreshAccessToken( + @GetToken() token: TokenInfo, + ) { + return this.googleService.refreshAccessToken(token.id); + } + + @ApiOperation({ summary: '获取已授权用户信息' }) + // @Public() + // @Get('auth/userinfo/:accessToken') + @Get('auth/userinfo') + async getUserInfo( + // @Param('accessToken') accessToken: string, + // @Query('userId') userId: string, + @GetToken() token: TokenInfo, + ) { + // return this.googleService.getUserInfo(accessToken, token); + console.log("前端传来的token: ", token); + const accessToken = await this.googleService.getUserAccessToken(token.id); + return this.googleService.getUserInfo(accessToken, token); + } + + @ApiOperation({ summary: '查询用户已授权权限列表' }) + // @Public() + @Get('auth/scopes') + async getAccountScopes( + // @Param('accessToken') accessToken: string + @GetToken() token: TokenInfo, + ) { + const accessToken = await this.googleService.getUserAccessToken(token.id); + return this.googleService.getAccountScopes(accessToken); + } + +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.module.ts new file mode 100644 index 000000000..0457026fe --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.module.ts @@ -0,0 +1,32 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-27 17:36:19 + * @LastEditors: nevin + * @Description: google google B站模块 + */ +import { Module } from '@nestjs/common'; +// import { MongooseModule } from '@nestjs/mongoose'; +// import { User, UserSchema } from 'src/db/schema/user.schema'; +import { GoogleController } from './google.controller'; +import { GoogleService } from './google.service'; +import { UserModule } from 'src/user/user.module'; +import { ConfigModule } from '@nestjs/config'; +import { RedisModule } from 'src/lib/redis/redis.module'; +import googleConfig from 'config/google.config'; +// import { UserService } from 'src/user/user.service'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [googleConfig], + }), + // MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), + UserModule, + RedisModule, + ], + controllers: [GoogleController], + providers: [GoogleService], + exports: [GoogleService], +}) +export class GoogleModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.service.ts new file mode 100644 index 000000000..1d77bf573 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/google/google.service.ts @@ -0,0 +1,828 @@ +/* + * @Author: zhangwei + * @Date: 2025-05-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: zhangwei + * @Description: youtube + */ + +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import { createHash, createHmac } from 'crypto'; +import { AccessToken } from './comment'; +import { URLSearchParams } from 'url'; + +import { RedisService } from 'src/lib/redis/redis.service'; +import { getCurrentTimestamp } from 'src/util/time.util'; +import { getRandomString } from 'src/util'; +import { AuthService } from 'src/auth/auth.service'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { User, UserStatus } from 'src/db/schema/user.schema'; +import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const fs = require('fs'); +const readline = require('readline'); +const { google } = require('googleapis'); +// const OAuth2 = google.auth.OAuth2; + +@Injectable() +export class GoogleService { + private oauth2Client: any; + private webClientSecret: string; + private webClientId: string; + private webRenderBaseUrl: string; + + constructor( + private configService: ConfigService, + @InjectModel(User.name) // 注入 User 模型 + private userModel: Model, + + private readonly redisService: RedisService, + @Inject(forwardRef(() => AuthService)) + private readonly AuthService: AuthService, + + ) { + this.oauth2Client = new google.auth.OAuth2(); + this.initGoogleSecrets(); + } + + /** + * 初始化 OAuth2 客户端并设置凭证 + * @param accessToken 传入的 access_token + */ + setCredentials(accessToken: string) { + this.oauth2Client.setCredentials({ + access_token: accessToken, + }); + } + + /** + * 获取初始化好的 OAuth2 客户端 + * @returns 返回 OAuth2 客户端 + */ + getClient() { + return this.oauth2Client; + } + + async initGoogleSecrets() { + this.webClientSecret = this.configService.get("GOOGLE_CONFIG.WEB_CLIENT_SECRET"); + this.webClientId = this.configService.get("GOOGLE_CONFIG.WEB_CLIENT_ID"); + this.webRenderBaseUrl = this.configService.get("GOOGLE_CONFIG.WEB_RENDER_URL"); + } + + async getAuthCode(mail: string, platform: string, type: string) { + try { + // const { tokens } = await this.oauth2Client.getToken(code); + // this.oauth2Client.setCredentials(tokens); + // this.oauth2Client.credentials = tokens; + + + const state = getRandomString(8); + this.redisService.setKey(`google:state:${state}`, { mail }, 60 * 10); + + // 根据参数platform选择 平台的权限 + let platScope = ""; + const platformScopes = { + google: [ + "openid", + "email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/userinfo.profile" + ], + youtube: [ + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/userinfo.profile" + ], + meta: [ + "https://www.googleapis.com/auth/meta", + "https://www.googleapis.com/auth/userinfo.profile" + ] + }; + + if (platform === "google") { + platScope += "" + platformScopes.google.join(" "); + } else if (platformScopes.hasOwnProperty(platform)) { + platScope += " " + platformScopes[platform].join(" "); + } + + + const params = new URLSearchParams({ + // scope: "openid email https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.upload", + // scope: "openid https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.upload", + scope: platScope, + access_type: "offline", + include_granted_scopes: "true", + response_type: "code", + state: state, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/${platform}/auth/callback`, + client_id: this.webClientId, + // prompt: "none", // 默认 consent + prompt: "consent" // 强制重新授权 + // login_hint: mail, + }); + // const authUrl = `https://accounts.google.com/o/oauth2/v2/auth`; + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + + // const result = await axios.post<{ + // code: number; // 0; + // message: string; // '0'; + // ttl: number; // 1; + // request_id: string; // '7b753a287405461f5afa526a1f672094'; + // data: { + // upload_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + // }; + // }>(url, body.toString(), { + // headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + // // headers: { 'Content-Type': 'application/json' } + // }); + // // return result.data.data.upload_token; + + // return result.data; + + authUrl.search = params.toString(); + + return authUrl.toString(); // 返回生成的 URL + + } catch (err) { + console.log('Error while trying to retrieve access token', err); + return err; + }; + } + + + async getAuthCode_v2(platform: string, type: string) { + try { + const state = getRandomString(8); + // this.redisService.setKey(`google:state:${state}`, { mail }, 60 * 10); + + console.log("++++++++++++++++++++++++") + console.log(this.webClientId); + console.log("++++++++++++++++++++++++") + // 根据参数platform选择 平台的权限 + let platScope = ""; + const platformScopes = { + google: [ + "openid", + "email", + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/userinfo.profile" + ], + youtube: [ + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/userinfo.profile" + ], + meta: [ + "https://www.googleapis.com/auth/meta", + "https://www.googleapis.com/auth/userinfo.profile" + ] + }; + + if (platform === "google") { + platScope += "" + platformScopes.google.join(" "); + } else if (platformScopes.hasOwnProperty(platform)) { + platScope += " " + platformScopes[platform].join(" "); + } + + const params = new URLSearchParams({ + // scope: "openid email https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.upload", + // scope: "openid https://www.googleapis.com/auth/youtube.force-ssl https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.upload", + scope: platScope, + access_type: "offline", + include_granted_scopes: "true", + response_type: "code", + state: state, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/google/auth/accessToken`, + client_id: this.webClientId, + // prompt: "none", // 默认 consent + // prompt: "consent" + // login_hint: mail, + }); + // const authUrl = `https://accounts.google.com/o/oauth2/v2/auth`; + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + + // const result = await axios.post<{ + // code: number; // 0; + // message: string; // '0'; + // ttl: number; // 1; + // request_id: string; // '7b753a287405461f5afa526a1f672094'; + // data: { + // upload_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + // }; + // }>(url, body.toString(), { + // headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + // // headers: { 'Content-Type': 'application/json' } + // }); + // // return result.data.data.upload_token; + + // return result.data; + + authUrl.search = params.toString(); + + return authUrl.toString(); // 返回生成的 URL + + } catch (err) { + console.log('Error while trying to retrieve access token', err); + return err; + }; + } + + + /** + * 获取授权Token + * @param code + * @returns + */ + async setUserAccessToken(data: { + code: string; + state: string; + }) { + + const { code, state } = data; + console.log("================ code state=======================") + console.log(code, state); + console.log("=============================================") + + try { + const params = new URLSearchParams({ + code: code, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/google/auth/accessToken`, + client_id: this.webClientId, + grant_type: "authorization_code", + client_secret: this.webClientSecret, + }); + const tokenUrl = `https://oauth2.googleapis.com/token`; + + const result = await axios.post<{ + access_token: string; + expires_in: number; + token_type: string; + refresh_token: string; + id_token: string; + }>(tokenUrl , params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + const mailInfo = await this.redisService.get(`google:state:${state}`); + // console.log("----------------"); + // console.log(result.data); + // console.log("mail:", mailInfo); + const accessTokenInfo = result.data; + + // 初始化 OAuth2 客户端 + // const oAuth2Client = new google.auth.OAuth2(); + this.oauth2Client.setCredentials({ + access_token: accessTokenInfo.access_token, + }); + + const idToken = accessTokenInfo.id_token; + const ticket = await this.oauth2Client.verifyIdToken({ idToken, audience: this.webClientId }); + const googleUser = ticket.getPayload(); + const googleAccount = { // Google 唯一 ID + googleId: googleUser.sub, + email: googleUser.email, + accessToken: result.data.access_token, + refreshToken: result.data.refresh_token, + expiresAt: getCurrentTimestamp() + result.data.expires_in + }; + + let userInfo: User | null = null; + // 优先用 Google ID 查找(最准确) + if (googleUser.sub) { + userInfo = await this.userModel.findOne({ + 'googleAccount.googleId': googleUser.sub, + status: UserStatus.OPEN, + }); + } + // console.log("这里的info:", userInfo, googleUser.sub, googleUser.email) + // 如果没有找到,再用 email 兜底查找(存在被别人注册的风险,需谨慎) + if (!userInfo && googleUser.email) { + userInfo = await this.userModel.findOne({ + mail: googleUser.email, + status: UserStatus.OPEN, + }); + } + console.log("这里的info2222:", userInfo, googleUser.email) + + if (userInfo) { + // 绑定 Google 信息 + // userInfo.googleId = googleId; + // userInfo.mail = mailInfo.mail; + userInfo.googleAccount = googleAccount + // if(userInfo.googleAccount.googleId=== googleAccount.googleId) { + // userInfo.googleAccount = googleAccount; // 覆盖旧 token + // } else { + // userInfo.googleAccount.push(googleAccount); // 添加新 Google 帐号 + // } + + console.log("更新后的数据:--", userInfo.googleAccount, userInfo.id) + await this.userModel.updateOne( + { _id: Object(userInfo.id) }, + { $set: { googleAccount: userInfo.googleAccount } }, + ); + + // return userInfo; + } else { + console.log("用户不存在,创建"); + const newData = { + name: `用户_${getRandomString(8)}`, + mail: mailInfo.mail, + googleAccount: googleAccount, + status: UserStatus.OPEN, + }; + + userInfo = await this.userModel.create(newData); + // console.log("新创建的用户:", userInfo); + } + console.log("userInfo22222:---", userInfo); + const userId = userInfo.id + + this.redisService.del(`UserInfo:${userInfo.id}`); + + const TokenInfo = { + phone: userInfo.phone, + id: userInfo.id, + name: userInfo.name, + isManager: false, + googleId: userInfo.googleAccount.googleId + } + console.log("发送获取systemToken的info---", TokenInfo); + const systemToken = await this.AuthService.generateToken(TokenInfo) + // 剩余有效秒数 + // const expires = + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + // const expires = 30 * 24 * 60 * 60 + console.log("accessTokenInfo:---", accessTokenInfo); + // console.log("expires:---", accessTokenInfo.expires_in); + this.redisService.setKey( + `google:accessToken:${userId}`, + accessTokenInfo, + accessTokenInfo.expires_in + ); + + // return result.data; + return systemToken; + } catch (err) { + console.log('Error while trying to retrieve access token', err); + return err; + }; + } + + /** + * Google登录 + * @param clientId Google客户端ID + * @param credential Google认证凭证 + * @returns Account + */ + async googleLogin(clientId: string, credential: string): Promise { + try { + console.log('Verifying Google token with:'); + // 验证Google token + const ticket = await this.oauth2Client.verifyIdToken({ + idToken: credential, + audience: clientId, + }); + console.log('ticket',ticket) + const googleUser = ticket.getPayload(); + console.log('payload',googleUser) + if (!googleUser) { + throw new Error('Invalid Google token'); + } + + console.log('Google login success, googleUser:', googleUser); + + const googleAccount = { + googleId: googleUser.sub, + email: googleUser.email, + // accessToken: result.data.access_token, + refreshToken: null, + // expiresAt: result.data.expires_in + }; + + let userInfo: User | null = null; + // 优先用 Google ID 查找(最准确) + if (googleUser.sub) { + userInfo = await this.userModel.findOne({ + 'googleAccount.googleId': googleUser.sub, + status: UserStatus.OPEN, + }); + } + // console.log("这里的info:", userInfo, googleUser.sub, googleUser.email) + // 如果没有找到,再用 email 兜底查找(存在被别人注册的风险,需谨慎) + if (!userInfo && googleUser.email) { + userInfo = await this.userModel.findOne({ + mail: googleUser.email, + status: UserStatus.OPEN, + }); + } + console.log("这里的info2222:", userInfo, googleUser.email) + + if (userInfo) { + console.log("已有用户,更新绑定的 googleAccount:", userInfo); + // 绑定 Google 信息 + // userInfo.googleId = googleId; + // userInfo.mail = mailInfo.mail; + // userInfo.googleAccount = googleAccount + // 合并逻辑:仅在新 refresh_token 存在时覆盖旧的 + const updatedGoogleAccount = { + ...userInfo.googleAccount, // 原有内容 + ...googleAccount, // 新数据覆盖(但不覆盖 refresh_token) + refreshToken: googleAccount.refreshToken != null && googleAccount.refreshToken !== '' + ? googleAccount.refreshToken + : userInfo.googleAccount?.refreshToken, + }; + + console.log("更新后的数据:--", updatedGoogleAccount, userInfo.id); + + // console.log("更新后的数据:--", userInfo.googleAccount, userInfo.id) + // await this.userModel.updateOne( + // { _id: Object(userInfo.id) }, + // { $set: { googleAccount: userInfo.googleAccount } }, + // ); + await this.userModel.updateOne( + { _id: Object(userInfo.id) }, + { $set: { googleAccount: updatedGoogleAccount } }, + ); + + // return userInfo; + } else { + console.log("用户不存在,创建"); + const newData = { + name: `用户_${getRandomString(8)}`, + mail: googleUser.email, + googleAccount: googleAccount, + status: UserStatus.OPEN, + }; + + userInfo = await this.userModel.create(newData); + // console.log("新创建的用户:", userInfo); + } + console.log("userInfo22222:---", userInfo); + this.redisService.del(`UserInfo:${userInfo.id}`); + + const TokenInfo = { + phone: userInfo.phone, + id: userInfo.id, + name: userInfo.name, + isManager: false, + createTime: userInfo.createTime, + mail: userInfo.mail, + status: userInfo.status, + updateTime: userInfo.updateTime, + googleAccount: userInfo.googleAccount + } + console.log("发送获取systemToken的info---", TokenInfo); + const systemToken = await this.AuthService.generateToken(TokenInfo) + // 剩余有效秒数 + // const expires = + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + // const expires = 30 * 24 * 60 * 60 + // console.log("accessTokenInfo:---", accessTokenInfo); + // console.log("expires:---", accessTokenInfo.expires_in); + // this.redisService.setKey( + // `google:accessToken:${userId}`, + // accessTokenInfo, + // accessTokenInfo.expires_in + // ); + const loginResult = { + token: null, + type: "login", + userInfo: TokenInfo + } + loginResult.token = systemToken + return loginResult; + } catch (error) { + console.error('Google login error:', error); + throw new Error(`Google login failed: ${error.message}`); + } + } + + async setUserAccessToken_V2(data: { + code: string; + state: string; + }) { + + const { code, state } = data; + console.log("================ code state=======================") + console.log(code, state); + console.log("=============================================") + + try { + const params = new URLSearchParams({ + code: code, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/google/auth/accessToken`, + client_id: this.webClientId, + grant_type: "authorization_code", + client_secret: this.webClientSecret, + }); + const tokenUrl = `https://oauth2.googleapis.com/token`; + + const result = await axios.post<{ + access_token: string; + expires_in: number; + token_type: string; + refresh_token: string; + id_token: string; + }>(tokenUrl , params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + // headers: { 'Content-Type': 'application/json' } + }); + + // const mailInfo = await this.redisService.get(`google:state:${state}`); + console.log("----------------"); + console.log(result.data); + // console.log("mail:", mailInfo); + const accessTokenInfo = result.data; + + // 初始化 OAuth2 客户端 + // const oAuth2Client = new google.auth.OAuth2(); + this.oauth2Client.setCredentials({ + access_token: accessTokenInfo.access_token, + }); + + const idToken = accessTokenInfo.id_token; + const ticket = await this.oauth2Client.verifyIdToken({ idToken, audience: this.webClientId }); + const googleUser = ticket.getPayload(); + console.log(googleUser); + // const googleId = googleUser.sub; // Google 唯一 ID + const googleAccount = { + googleId: googleUser.sub, + email: googleUser.email, + accessToken: result.data.access_token, + refreshToken: result.data.refresh_token, + expiresAt: getCurrentTimestamp() + result.data.expires_in + }; + + let userInfo: User | null = null; + // 优先用 Google ID 查找(最准确) + if (googleUser.sub) { + userInfo = await this.userModel.findOne({ + 'googleAccount.googleId': googleUser.sub, + status: UserStatus.OPEN, + }); + } + // console.log("这里的info:", userInfo, googleUser.sub, googleUser.email) + // 如果没有找到,再用 email 兜底查找(存在被别人注册的风险,需谨慎) + if (!userInfo && googleUser.email) { + userInfo = await this.userModel.findOne({ + mail: googleUser.email, + status: UserStatus.OPEN, + }); + } + console.log("这里的info2222:", userInfo, googleUser.email) + + if (userInfo) { + console.log("已有用户,更新绑定的 googleAccount:", userInfo); + // 绑定 Google 信息 + // userInfo.googleId = googleId; + // userInfo.mail = mailInfo.mail; + // userInfo.googleAccount = googleAccount + // 合并逻辑:仅在新 refresh_token 存在时覆盖旧的 + const updatedGoogleAccount = { + ...userInfo.googleAccount, // 原有内容 + ...googleAccount, // 新数据覆盖(但不覆盖 refresh_token) + refreshToken: googleAccount.refreshToken != null && googleAccount.refreshToken !== '' + ? googleAccount.refreshToken + : userInfo.googleAccount?.refreshToken, + }; + + console.log("更新后的数据:--", updatedGoogleAccount, userInfo.id); + + // console.log("更新后的数据:--", userInfo.googleAccount, userInfo.id) + // await this.userModel.updateOne( + // { _id: Object(userInfo.id) }, + // { $set: { googleAccount: userInfo.googleAccount } }, + // ); + await this.userModel.updateOne( + { _id: Object(userInfo.id) }, + { $set: { googleAccount: updatedGoogleAccount } }, + ); + + // return userInfo; + } else { + console.log("用户不存在,创建"); + const newData = { + name: `用户_${getRandomString(8)}`, + mail: googleUser.email, + googleAccount: googleAccount, + status: UserStatus.OPEN, + }; + + userInfo = await this.userModel.create(newData); + // console.log("新创建的用户:", userInfo); + } + console.log("userInfo22222:---", userInfo); + const userId = userInfo.id + + this.redisService.del(`UserInfo:${userInfo.id}`); + + const TokenInfo = { + phone: userInfo.phone, + id: userInfo.id, + name: userInfo.name, + isManager: false, + googleId: userInfo.googleAccount.googleId, + mail: userInfo.mail + } + console.log("发送获取systemToken的info---", TokenInfo); + const systemToken = await this.AuthService.generateToken(TokenInfo) + // 剩余有效秒数 + // const expires = + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + // const expires = 30 * 24 * 60 * 60 + console.log("accessTokenInfo:---", accessTokenInfo); + // console.log("expires:---", accessTokenInfo.expires_in); + this.redisService.setKey( + `google:accessToken:${userId}`, + accessTokenInfo, + accessTokenInfo.expires_in + ); + + const loginResult = { + token: null, + type: "login", + userInfo: TokenInfo + } + loginResult.token = systemToken + return loginResult; + + // return result.data; + // return systemToken; + } catch (err) { + console.log('Error while trying to retrieve access token', err); + return err; + }; + } + + async getUserAccessToken(userId: string): Promise { + const res: AccessToken = await this.redisService.get( + `google:accessToken:${userId}`, + ); + // if (!res) return ''; + + // // 剩余时间 + // const overTime = res.expires_in; + + // if (overTime < 60 * 60 && overTime > 0) { + // // 刷新token + // this.refreshAccessToken(userId, res.refresh_token); + // } + const userInfo = await this.userModel.findOne({_id: userId}); + this.refreshAccessToken(userId, userInfo.googleAccount.refreshToken); + return res.access_token; + } + + /** + * 刷新授权Token + * @param refreshToken + * @returns + */ + async refreshAccessToken(userId: string, refreshToken?: string) { + try { + const userInfo = await this.userModel.findOne({_id: userId}); + if(!refreshToken) { + + console.log("=============userInfo===================="); + console.log(userInfo) + refreshToken = userInfo?.googleAccount?.refreshToken ?? '' + } + + const tokenUrl = 'https://oauth2.googleapis.com/token'; + + // 请求体的参数 + const params = new URLSearchParams({ + client_id: this.webClientId, // 使用你的 client_id + client_secret: this.webClientSecret, // 使用你的 client_secret + refresh_token: refreshToken, // 提供刷新令牌 + grant_type: 'refresh_token', // 认证类型是刷新令牌 + }); + + // 发送 POST 请求到 Google token endpoint 来刷新 access token + const response = await axios.post(tokenUrl, params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + console.log("================response================") + console.log(response); + const accessTokenInfo = response.data; + // console.log("================accessTokenInfo================") + // console.log(accessTokenInfo); + // 剩余有效秒数 + const expires = accessTokenInfo.expires_in + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + this.redisService.setKey( + `google:accessToken:${userId}`, + accessTokenInfo, + expires, + ); + + + const TokenInfo = { + phone: userInfo?.phone ?? '', // 如果 userInfo.phone 为 undefined 或 null,则使用空字符串 + id: userId, + name: userInfo.name, + isManager: false, + googleId: userInfo?.googleAccount?.googleId ?? '' + } + console.log("发送获取systemToken的info---", TokenInfo); + const systemToken = await this.AuthService.generateToken(TokenInfo) + // 剩余有效秒数 + // const expires = + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + + // const expires = 30 * 24 * 60 * 60 + console.log("accessTokenInfo:---", accessTokenInfo); + // console.log("expires:---", accessTokenInfo.expires_in); + this.redisService.setKey( + `google:accessToken:${userId}`, + accessTokenInfo, + accessTokenInfo.expires_in + ); + + // return result.data; + return systemToken; + + + // 返回新的 access token 和其他信息 + // return response.data; // 包含新的 access_token、expires_in、token_type 等信息 + } catch (err) { + console.log('Error while refreshing access token', err); + throw new Error('Failed to refresh access token'); + } + } + + /** + * 查询用户已授权权限列表 + * @returns + */ + async getAccountScopes(accessToken: string) { + try { + // 初始化 OAuth2 客户端 + // const oAuth2Client = new google.auth.OAuth2(); + this.oauth2Client.setCredentials({ + access_token: accessToken, + }); + + // 通过访问 token 查询 token 信息 + const tokenInfo = await google.oauth2('v2').tokeninfo({ + access_token: accessToken, + }); + + console.log('Token Info:', tokenInfo.data); + + // 查询用户的基本信息 + const userInfo = await google.oauth2('v2').userinfo.get({ + auth: this.oauth2Client, + }); + + console.log('User Info:', userInfo.data); + + // 返回用户的权限和信息 + return { + tokenInfo: tokenInfo.data, + userInfo: userInfo.data, + }; + } catch (error) { + console.error('Error getting user permissions:', error); + throw new Error('Failed to get user permissions'); + } + } + + /** + * 获取已授权的用户信息 + * @param data + */ + async getUserInfo(accessToken: string, token) { + try { + const response = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + // 返回用户信息 + return response.data; + } catch (err) { + console.error('Error fetching user info:', err); + throw new Error('Failed to fetch user info'); + } + } + +} + + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/comment.ts new file mode 100644 index 000000000..389fec52d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/comment.ts @@ -0,0 +1,4 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/dto/bilibili.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/dto/bilibili.dto.ts new file mode 100644 index 000000000..c06a44575 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/dto/bilibili.dto.ts @@ -0,0 +1,114 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:16:37 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { VideoUTypes } from '../comment'; + +export class AccessBackDto { + @Expose() + code: string; + + @Expose() + state: string; +} + +export class VideoInitDto { + @ApiProperty({ title: '文件名称(带格式)', required: true }) + @IsString({ message: '文件名称' }) + @Expose() + readonly fileName: string; + + @ApiProperty({ enum: VideoUTypes }) + @IsNotEmpty({ message: '文件类型不能为空' }) + @IsEnum(VideoUTypes) + @Type(() => Number) + @Expose() + readonly utype: VideoUTypes; +} + +export class ArchiveAddByUtokenQueryDto { + @ApiProperty({ title: '用户token', required: true }) + @IsString({ message: '用户token' }) + @Expose() + readonly accessToken: string; + + @ApiProperty({ title: '上传token', required: true }) + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string; +} + +export class ArchiveAddByUtokenBodyDto { + @ApiProperty({ title: '标题', required: true }) + @IsString({ message: '标题' }) + @Expose() + readonly title: string; + + @ApiProperty({ title: '封面url', required: false }) + @IsString({ message: '封面url' }) + @IsOptional() + @Expose() + readonly cover?: string; + + @ApiProperty({ title: '分区ID,由获取分区信息接口得到', required: true }) + @IsNumber({ allowNaN: false }, { message: '分区ID,由获取分区信息接口得到' }) + @Expose() + readonly tid: number; + + @ApiProperty({ + title: '是否允许转载 0-允许,1-不允许。默认0', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '是否允许转载 0-允许,1-不允许。默认0' }, + ) + @IsOptional() + @Expose() + readonly noReprint?: 0 | 1; + + @ApiProperty({ title: '描述', required: false }) + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string; + + @ApiProperty({ title: '标签', required: false }) + @IsArray({ message: '标签必须是字符串数组' }) + @IsString({ each: true, message: '描述' }) + @Expose() + readonly tag: string[]; + + @ApiProperty({ + title: '1-原创,2-转载(转载时source必填)', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '1-原创,2-转载(转载时source必填)' }, + ) + @Expose() + readonly copyright: 1 | 2; + + @ApiProperty({ + title: '如果copyright为转载,则此字段表示转载来源', + required: false, + }) + @IsString({ message: '如果copyright为转载,则此字段表示转载来源' }) + @IsOptional() + @Expose() + readonly source?: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.controller.ts new file mode 100644 index 000000000..49420589d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.controller.ts @@ -0,0 +1,93 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + AccessBackDto, + ArchiveAddByUtokenBodyDto, + ArchiveAddByUtokenQueryDto, +} from './dto/bilibili.dto'; +import { GzhService } from './gzh.service'; +import { Public } from 'src/auth/auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; + +@ApiTags('plat/gzh - B站平台') +@Controller('plat/gzh') +export class GzhController { + constructor(private readonly bilibiliService: GzhService) {} + + @ApiOperation({ summary: '获取AccessToken' }) + @Public() + @Get('accessToken') + async getAccessToken(@Query() query: AccessBackDto) { + // const res = await this.bilibiliService.getAccessToken(query.code); + // return res; + } + + @ApiOperation({ summary: '刷新授权Token' }) + @Get('refreshAccessToken/:refreshToken') + async refreshAccessToken(@Param('refreshToken') refreshToken: string) { + return this.bilibiliService.refreshAccessToken(refreshToken); + } + + @ApiOperation({ summary: '查询用户已授权权限列表' }) + @Get('account/scopes/:accessToken') + async getAccountScopes(@Param('accessToken') accessToken: string) { + return this.bilibiliService.getAccountScopes(accessToken); + } + + @ApiOperation({ summary: '视频初始化' }) + @Post('video/init/:accessToken') + async videoInit(@Param('accessToken') accessToken: string) { + return this.bilibiliService.videoInit(accessToken); + } + + @ApiOperation({ summary: '封面上传' }) + @UseInterceptors(FileInterceptor('file')) + @Post('cover/upload') + async coverUpload( + @Param('accessToken') accessToken: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.bilibiliService.coverUpload(accessToken, file); + } + + @ApiOperation({ summary: '视频稿件提交' }) + @Post('archive/add-by-utoken') + async archiveAddByUtoken( + @Query() query: ArchiveAddByUtokenQueryDto, + @Body() body: ArchiveAddByUtokenBodyDto, + ) { + const data = { + ...body, + no_reprint: body.noReprint, + tag: body.tag.join(','), + }; + const { accessToken, uploadToken } = query; + // return this.bilibiliService.archiveAddByUtoken( + // accessToken, + // uploadToken, + // data, + // ); + } + + @ApiOperation({ summary: '分区查询' }) + @Get('archive/type/list/:accessToken') + async archiveTypeList(@Param('accessToken') accessToken: string) { + return this.bilibiliService.archiveTypeList(accessToken); + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.module.ts new file mode 100644 index 000000000..3f769aedb --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.module.ts @@ -0,0 +1,22 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-27 17:36:19 + * @LastEditors: nevin + * @Description: Gzh 公众号模块 + */ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { SignIn, SignInSchema } from 'src/db/schema/signIn.schema'; +import { GzhController } from './gzh.controller'; +import { GzhService } from './gzh.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([{ name: SignIn.name, schema: SignInSchema }]), + ], + controllers: [GzhController], + providers: [GzhService], + exports: [GzhService], +}) +export class GzhModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.service.ts new file mode 100644 index 000000000..b0c800f12 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/gzh/gzh.service.ts @@ -0,0 +1,224 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: nevin + * @Description: gzh Gzh 公众号 + */ +import { Injectable } from '@nestjs/common'; +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; +import { createHash, createHmac } from 'crypto'; +import { VideoUTypes } from './comment'; + +@Injectable() +export class GzhService { + private componentAppid = ''; + private componentAppsecret = ''; + constructor() {} + + /** + * 获取授权Token + * @param ticket 票据 + * @returns + */ + async getComponentAccessToken(ticket: string) { + const url = `https://api.weixin.qq.com/cgi-bin/component/api_component_token`; + const result = await axios.post<{ + component_access_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number; // 1630220614; + }>(url, { + component_appid: this.componentAppid, + component_appsecret: this.componentAppsecret, + component_verify_ticket: ticket, + }); + + return result.data; + } + + /** + * 刷新授权Token + * @param refreshToken + * @returns + */ + async refreshAccessToken(refreshToken: string) { + return null; + } + + /** + * 查询用户已授权权限列表 + * @returns + */ + async getAccountScopes(accessToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/user/account/scopes`; + const result = await axios.get<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + data: { + openid: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + }; + }>(url, { + headers: this.generateBilibiliHeader({ + accessToken, + isForm: true, + }), + }); + + return result.data.data; + } + + /** + * 生成请求头 + * @param data + */ + private generateBilibiliHeader(data: { + accessToken: string; + body?: { [key: string]: any }; + isForm?: boolean; + }) { + const { accessToken, body, isForm } = data; + const xBiliContentMd5 = body + ? createHash('md5').update(JSON.stringify(body)).digest('hex') + : ''; + + const header = { + Accept: 'application/json', + 'Content-Type': isForm ? 'multipart/form-data' : 'application/json', // 或者 multipart/form-data + 'x-bili-content-md5': xBiliContentMd5, + 'x-bili-timestamp': Math.floor(Date.now() / 1000), + 'x-bili-signature-method': 'HMAC-SHA256', + 'x-bili-signature-nonce': uuidv4(), + 'x-bili-signature-version': '1.0', + 'access-token': accessToken, // 需要在请求头中添加access-token + Authorization: '', + }; + + // 抽取带”x-bili-“前缀的自定义header,按字典排序拼接,构建完整的待签名字符串: + // 待签名字符串包含换行符\n + const headerStr = Object.keys(header) + .filter((key) => key.startsWith('x-bili-')) + .sort() + .map((key) => `${key}:${header[key]}\n`) + .join(''); + + // 使用 createHmac 正确创建签名 + const signature = createHmac('sha256', '').update(headerStr).digest('hex'); + + // 将签名加入 header + header.Authorization = signature; + + return header; + } + + /** + * 视频初始化 + * @param fileName + * @param utype // 1-单个小文件(不超过100M)。默认值为0 + * @returns + */ + async videoInit(fileName: string, utype: VideoUTypes = 0): Promise { + const body = { + name: fileName, // test.mp4 + utype, + }; + + const url = `https://member.bilibili.com/arcopen/fn/archive/video/init`; + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '7b753a287405461f5afa526a1f672094'; + data: { + upload_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + }; + }>(url, body); + return result.data.data.upload_token; + } + + /** + * 封面上传 + * @param accessToken + * @param file + * @returns + */ + async coverUpload( + accessToken: string, + file: Express.Multer.File, + ): Promise { + const url = `https://member.bilibili.com/arcopen/fn/archive/cover/upload`; + + const formData = new FormData(); + const blob = new Blob([file.buffer], { type: file.mimetype }); + formData.append('file', blob, file.originalname); + + const result = await axios.post<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '7b753a287405461f5afa526a1f672094'; + data: { + url: string; // "https://archive.biliimg.com/bfs/..." + }; + }>(url, formData, { + headers: this.generateBilibiliHeader({ + accessToken: accessToken, + isForm: true, + }), + }); + return result.data.data.url; + } + + /** + * 公众号素材发布 + * @param accessToken + * @param mediaId + * @returns + */ + async freepublishSubmit( + accessToken: string, + mediaId: string, + ): Promise { + const url = `https://api.weixin.qq.com/cgi-bin/freepublish/submit?access_token=${accessToken}`; + + const result = await axios.post<{ + errcode: number; // 0; + errmsg: string; // 'ok'; + publish_id: string; // '100000001'; + }>( + url, + { + media_id: mediaId, + }, + { + headers: this.generateBilibiliHeader({ + accessToken, + }), + }, + ); + return result.data.publish_id; + } + + /** + * 分区查询 + * @param accessToken + * @returns + */ + async archiveTypeList(accessToken: string) { + const url = `https://member.bilibili.com/arcopen/fn/archive/type/list`; + + const result = await axios.get<{ + code: number; // 0; + message: string; // '0'; + ttl: number; // 1; + request_id: string; // '35f4a1e0d3765a92510f919d0b6721dd'; + data: any; + }>(url, { + headers: this.generateBilibiliHeader({ + accessToken, + }), + }); + return result.data.data; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/plat.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/plat.module.ts new file mode 100644 index 000000000..e0415d472 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/plat.module.ts @@ -0,0 +1,19 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-27 17:36:19 + * @LastEditors: nevin + * @Description: plat Plat 三方平台模块 + */ +import { Module } from '@nestjs/common'; +import { BilibiliModule } from './bilibili/bilibili.module'; +import { GzhModule } from './gzh/gzh.module'; +import { GoogleModule } from './google/google.module'; +import { YoutubeModule } from './youtube/youtube.module'; +import { TwitterModule } from './twitter/twitter.module'; +import { TiktokModule } from './tiktok/tiktok.module'; + +@Module({ + imports: [BilibiliModule, GzhModule, GoogleModule, YoutubeModule, TwitterModule, TiktokModule], +}) +export class PlatModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/comment.ts new file mode 100644 index 000000000..e69de29bb diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/dto/tiktok.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/dto/tiktok.dto.ts new file mode 100644 index 000000000..ce84bdb2e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/dto/tiktok.dto.ts @@ -0,0 +1,158 @@ +import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, IsUrl } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateVideoDto { + @ApiProperty({ description: 'TikTok账号ID' }) + @IsString() + accountId: string; + + @ApiProperty({ description: '视频描述/标题' }) + @IsString() + description: string; + + @ApiProperty({ description: '视频是否为私有', required: false, default: false }) + @IsBoolean() + @IsOptional() + private?: boolean; + + @ApiProperty({ description: '视频话题标签列表', required: false, type: [String] }) + @IsArray() + @IsOptional() + hashtags?: string[]; +} + +export class TikTokCommentDto { + @ApiProperty({ description: 'TikTok账号ID' }) + @IsString() + accountId: string; + + @ApiProperty({ description: '视频ID' }) + @IsString() + videoId: string; + + @ApiProperty({ description: '评论内容' }) + @IsString() + text: string; +} + +export class GetVideosQueryDto { + @ApiProperty({ description: 'TikTok账号ID' }) + @IsString() + accountId: string; + + @ApiProperty({ description: '每页结果数', required: false, default: 10 }) + @IsNumber() + @IsOptional() + limit?: number; + + @ApiProperty({ description: '用于分页的游标', required: false }) + @IsString() + @IsOptional() + cursor?: string; +} + +export class TikTokVideoFilterDto { + @ApiProperty({ description: 'TikTok账号ID' }) + @IsString() + accountId: string; + + @ApiProperty({ description: '关键词搜索', required: false }) + @IsString() + @IsOptional() + keyword?: string; + + @ApiProperty({ description: '最低播放次数', required: false }) + @IsNumber() + @IsOptional() + minPlayCount?: number; + + @ApiProperty({ description: '每页结果数', required: false, default: 10 }) + @IsNumber() + @IsOptional() + limit?: number; + + @ApiProperty({ description: '用于分页的游标', required: false }) + @IsString() + @IsOptional() + cursor?: string; +} + +export interface TikTokOAuthTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope: string; + open_id: string; +} + +export interface TikTokUser { + open_id: string; + union_id: string; + username: string; + display_name: string; + avatar_url: string; + bio_description: string; + profile_deep_link: string; + is_verified: boolean; + follower_count: number; + following_count: number; + likes_count: number; + video_count: number; +} + +export class CombinedVideoUploadDto { + @ApiProperty({ description: 'TikTok账号ID' }) + @IsString() + accountId: string; + + @ApiProperty({ description: '视频标题', required: false }) + @IsString() + @IsOptional() + title?: string; + + @ApiProperty({ description: '视频描述', required: false }) + @IsString() + @IsOptional() + description?: string; + + @ApiProperty({ description: '隐私级别', required: false, default: 'PUBLIC', enum: ['PUBLIC', 'PRIVATE', 'FRIENDS'] }) + @IsString() + @IsOptional() + privacyStatus?: string; + + @ApiProperty({ description: '是否禁用评论', required: false, default: false }) + @IsBoolean() + @IsOptional() + disableComment?: boolean; + + @ApiProperty({ description: '是否禁用二重奏', required: false, default: false }) + @IsBoolean() + @IsOptional() + disableDuet?: boolean; + + @ApiProperty({ description: '是否禁用Stitch', required: false, default: false }) + @IsBoolean() + @IsOptional() + disableStitch?: boolean; + + @ApiProperty({ description: '视频封面时间点(毫秒)', required: false }) + @IsNumber() + @IsOptional() + videoCoverTimestampMs?: number; + + @ApiProperty({ description: '话题标签列表', required: false, type: [String] }) + @IsArray() + @IsOptional() + tags?: string[]; + + @ApiProperty({ description: '轮询间隔(毫秒)', required: false, default: 2000 }) + @IsNumber() + @IsOptional() + pollInterval?: number; + + @ApiProperty({ description: '最大重试次数', required: false, default: 30 }) + @IsNumber() + @IsOptional() + maxRetries?: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.auth.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.auth.service.ts new file mode 100644 index 000000000..de7f77bcf --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.auth.service.ts @@ -0,0 +1,587 @@ +import { Injectable, Inject, forwardRef, BadRequestException, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'crypto'; + +import { RedisService } from 'src/lib/redis/redis.service'; +import { IdService } from 'src/db/id.service'; + +import { AuthService } from 'src/auth/auth.service'; +import { AccountService } from 'src/modules/account/account.service'; +import { User } from 'src/db/schema/user.schema'; +import { Account, AccountType, AccountStatus } from 'src/db/schema/account.schema'; +import { AccountToken, TokenPlatform, TokenStatus } from 'src/db/schema/accountToken.schema'; +import { TikTokOAuthTokenResponse, TikTokUser } from './dto/tiktok.dto'; + +// 工具函数 +function getCurrentTimestamp(): number { + return Math.floor(Date.now() / 1000); +} + +@Injectable() +export class TikTokAuthService { + private readonly logger = new Logger(TikTokAuthService.name); + private clientId: string; + private clientSecret: string; + private redirectUri: string; + private authUrl: string; + private tokenUrl: string; + private revokeUrl: string; + private refreshTokenUrl: string; + private apiBaseUrl: string; + private scopes: string; + + private state: string; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + private readonly redisService: RedisService, + + @Inject(forwardRef(() => AuthService)) + private readonly authService: AuthService, + + @Inject(forwardRef(() => AccountService)) + private readonly accountService: AccountService, + + @InjectModel(User.name) private readonly userModel: Model, + @InjectModel(Account.name) private readonly accountModel: Model, + @InjectModel(AccountToken.name) private readonly accountTokenModel: Model, + ) { + this.initTikTokSecrets(); + } + + /** + * 初始化TikTok API密钥和配置 + */ + private initTikTokSecrets() { + const tikTokConfig = this.configService.get('tiktok'); + if (!tikTokConfig) { + throw new Error('TikTok配置未找到,请检查环境变量和配置文件'); + } + + this.clientId = tikTokConfig.clientId; + this.clientSecret = tikTokConfig.clientSecret; + this.redirectUri = tikTokConfig.redirectUri; + this.authUrl = tikTokConfig.authUrl; + this.tokenUrl = tikTokConfig.tokenUrl; + this.revokeUrl = tikTokConfig.revokeUrl; + this.refreshTokenUrl = tikTokConfig.refreshTokenUrl; + this.apiBaseUrl = tikTokConfig.apiBaseUrl; + this.scopes = tikTokConfig.scopes; + + if (!this.clientId || !this.clientSecret) { + this.logger.error('TikTok客户端ID或密钥未设置'); + } + } + + /** + * 生成随机状态码用于OAuth流程 + */ + private generateState(): string { + return crypto.randomBytes(20).toString('hex'); + } + + /** + * 生成PKCE的code_verifier和code_challenge + * @returns 包含code_verifier和code_challenge的对象 + */ + private generatePKCE(): { codeVerifier: string; codeChallenge: string } { + // 生成随机码验证器 + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + + // 生成码挑战 + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + return { codeVerifier, codeChallenge }; + } + + /** + * 获取TikTok授权URL + * @param mail 用户邮箱 + * @returns 包含授权URL的对象 + */ + async getAuthorizationUrl(userId: string, mail: string): Promise { + if (!userId) { + throw new BadRequestException('userId是必需的'); + } + + // 生成状态参数以防止CSRF攻击 + const state = this.generateState(); + + // 生成PKCE的code_verifier和code_challenge + const { codeVerifier, codeChallenge } = this.generatePKCE(); + + const stateData = { + originalState: state, // 保留原始state值 + userId: userId, // 用户ID + email: mail, // 邮箱 + codeVerifier: codeVerifier // 保存code_verifier用于后续交换token + }; + + // 将状态与用户数据关联并存储在Redis中 (10分钟有效期) + await this.redisService.setKey(`tiktok:state:${state}`, JSON.stringify(stateData), 600); + + // 构建TikTok授权URL + // const authUrl = new URL(this.authUrl); + // authUrl.searchParams.append('client_key', this.clientId); + // authUrl.searchParams.append('response_type', 'code'); + // authUrl.searchParams.append('redirect_uri', `${this.redirectUri}/api/plat/tiktok/auth/callback`); + // authUrl.searchParams.append('scope', this.scopes); + // authUrl.searchParams.append('state', state); + // // 添加PKCE参数 + // authUrl.searchParams.append('code_challenge', codeChallenge); + // authUrl.searchParams.append('code_challenge_method', 'S256'); + + // return { + // url: authUrl.toString() + // }; + + // 构建授权URL参数 + const params = new URLSearchParams({ + response_type: 'code', + client_key: this.clientId, + redirect_uri: `${this.redirectUri}/api/plat/tiktok/auth/callback`, + scope: this.scopes, + state: state, + code_challenge: codeChallenge, + // disable_auto_auth: '0', + code_challenge_method: 'S256', // 使用SHA-256算法 + }); + + // 构建完整的授权URL + const authUrl = `${this.authUrl}?${params.toString()}`; + console.log('TikTok授权URL:', authUrl); + + return { url: authUrl }; + + } + + /** + * 处理TikTok授权回调 + * @param code 授权码 + * @param state 状态码 + * @returns 处理结果 + */ + async handleAuthorizationCallback(code: string, state: string): Promise { + // // 解析状态参数 + // let parsedState; + // try { + // parsedState = JSON.parse(decodeURIComponent(state)); + // } catch (error) { + // this.logger.error('无法解析状态参数:', error); + // throw new BadRequestException('无效的状态参数格式'); + // } + + // 从Redis获取保存的状态信息 + // const originalState = parsedState.state; + + const stateDataJson = await this.redisService.get(`tiktok:state:${state}`); + if (!stateDataJson) { + throw new BadRequestException('无效的状态参数或状态已过期'); + } + // 解析状态数据 + const stateData = JSON.parse(stateDataJson); + console.log('stateData:------', stateData); + const { userId, codeVerifier } = stateData; + if (!userId || !codeVerifier) { + throw new BadRequestException('状态数据不完整'); + } + + // 删除Redis中的状态信息 + await this.redisService.del(`tiktok:state:${state}`); + + try { + // 使用授权码交换令牌,并传入codeVerifier + const tokenResponse = await this.exchangeCodeForTokens(code, codeVerifier); + console.log("获取授权码成功!", tokenResponse); + // 获取用户信息 + const userProfile = await this.getTikTokUserProfile(tokenResponse.access_token, tokenResponse.open_id); + + // 更新或创建TikTok账户信息 + await this.updateTikTokAccountInfo( + userId, + userProfile.open_id, + tokenResponse.access_token, + tokenResponse.refresh_token, + tokenResponse.expires_in + ); + + // 保存访问令牌到Redis以便后续使用 + await this.redisService.setKey( + `tiktok:accessToken:${userProfile.open_id}`, + { + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expires_in: tokenResponse.expires_in, + expiry_time: getCurrentTimestamp() + tokenResponse.expires_in + }, + tokenResponse.expires_in - 300 // 令牌过期前5分钟 + ); + + // 生成系统令牌 + const userInfo = await this.userModel.findOne({ _id: userId }); + const systemTokenInfo = { + phone: userInfo?.phone ?? '', + id: userId, + name: userInfo.name, + isManager: false, + googleId: userInfo?.googleAccount?.googleId ?? '' + }; + + const systemToken = await this.authService.generateToken(systemTokenInfo); + + return { data: systemTokenInfo }; + } catch (error) { + this.logger.error('处理TikTok授权回调失败:', error); + throw new HttpException( + '授权TikTok账户失败: ' + (error.response?.data?.error_description || error.message), + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + /** + * 交换授权码获取令牌 + * @param code 授权码 + * @returns TikTok OAuth令牌响应 + */ + private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + try { + // 构建请求体 + const params = new URLSearchParams({ + client_key: this.clientId, + client_secret: this.clientSecret, + code: code, + grant_type: 'authorization_code', + redirect_uri: `${this.redirectUri}/api/plat/tiktok/auth/callback`, + // 添加PKCE code_verifier + // code_verifier: codeVerifier // Required for mobile and desktop app only. + }); + + // const base64Credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); + + const { data } = await firstValueFrom( + this.httpService.post(this.tokenUrl, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + // 'Authorization': `Basic ${base64Credentials}`, + } + }) + ); + + if (data.error) { + throw new BadRequestException(`交换令牌失败: ${data}`); + } + + // return { + // access_token: data.access_token, + // refresh_token: data.refresh_token, + // expires_in: data.expires_in, + // token_type: data.token_type, + // scope: data.scope, + // open_id: data.open_id + // }; + return data; + } catch (error) { + this.logger.error('交换TikTok授权码失败:', error); + throw new BadRequestException(`交换授权码失败: ${error.response?.data?.error_description || error.message}`); + } + } + + /** + * 获取TikTok用户资料 + * @param accessToken 访问令牌 + * @param openId 用户开放ID + * @returns TikTok用户资料 + */ + async getTikTokUserProfile(accessToken: string, openId: string): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/user/info/`, { + params: { + fields: 'open_id,union_id,avatar_url,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count,video_count,username, display_name', + // open_id: openId + }, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + console.log(data) + // if (data.error) { + // throw new BadRequestException(`获取用户信息失败: ${data.error.message}`); + // } + + return data.data.user; + } catch (error) { + this.logger.error('获取TikTok用户信息失败:', error); + throw new BadRequestException(`获取用户信息失败: ${error.response?.data?.error?.message || error.message || error.code}`); + } + } + + /** + * 更新TikTok账户信息 + * @param userId 用户ID + * @param tikTokId TikTok用户ID + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + * @param expires_in 令牌有效期(秒) + */ + async updateTikTokAccountInfo( + userId: string, + tikTokId: string, + accessToken: string, + refreshToken: string, + expires_in: number + ): Promise { + try { + // 获取TikTok用户信息 + const tikTokUser = await this.getTikTokUserProfile(accessToken, tikTokId); + + // 准备账号信息 + const channelInfo = { + userId: userId, + type: AccountType.TIKTOK, // 需要在AccountType中添加TIKTOK类型 + uid: tikTokId, + account: tikTokUser.username, + nickname: tikTokUser.display_name, + avatar: tikTokUser.avatar_url, + homePage: tikTokUser.profile_deep_link, + fansCount: tikTokUser.follower_count, + followCount: tikTokUser.following_count, + workCount: tikTokUser.video_count, + likeCount: tikTokUser.likes_count, + readCount: 0, + collectCount: 0, + forwardCount: 0, + commentCount: 0, + updateTime: new Date(), + status: AccountStatus.USABLE, + loginCookie: "1111", // TikTok不使用cookie认证 + token: "111", // 存储访问令牌 + }; + + // 使用AccountService创建或更新账户 + const account = await this.accountService.addOrUpdateAccount(channelInfo); + this.logger.log("成功创建或更新TikTok账号:", account); + + // 检查是否存在账号Token + let accountToken = await this.accountTokenModel.findOne({ + accountId: tikTokId, + platform: TokenPlatform.TIKTOK // 需要在TokenPlatform中添加TIKTOK类型 + }); + + if (accountToken) { + if (refreshToken && refreshToken.trim() !== '') { + accountToken.refreshToken = refreshToken; + } + accountToken.expiresAt = new Date((getCurrentTimestamp() + expires_in) * 1000); + accountToken.updateTime = new Date(); + await accountToken.save(); + this.logger.log("成功更新TikTok账号Token"); + } else { + // 创建新Token + await this.accountTokenModel.create({ + userId, + accountId: tikTokId, + platform: TokenPlatform.TIKTOK, // 需要在TokenPlatform中添加TIKTOK类型 + refreshToken: refreshToken, + expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000), + status: TokenStatus.USABLE, + createTime: new Date(), + updateTime: new Date(), + }); + this.logger.log("成功创建TikTok账号Token"); + } + } catch (error) { + this.logger.error('更新TikTok账号信息失败:', error); + // 不抛出异常,避免影响授权流程 + } + } + + /** + * 刷新用户的TikTok访问令牌 + * @param userId 用户ID + * @param accountId 账号ID + * @param refreshToken 刷新令牌 + * @returns 新的访问令牌信息 + */ + async refreshAccessToken(userId: string, accountId: string, refreshToken: string): Promise { + this.logger.log(`尝试刷新TikTok令牌: userId=${userId}, accountId=${accountId}`); + try { + const params = new URLSearchParams({ + client_key: this.clientId, + client_secret: this.clientSecret, + grant_type: 'refresh_token', + refresh_token: refreshToken + }); + + const { data } = await firstValueFrom( + this.httpService.post(this.refreshTokenUrl, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + if (data.error) { + throw new BadRequestException(`刷新令牌失败: ${data.error_description}`); + } + + // 保存新令牌到Redis + await this.redisService.setKey( + `tiktok:accessToken:${accountId}`, + { + access_token: data.access_token, + refresh_token: data.refresh_token || refreshToken, // 有些OAuth提供商在刷新时不返回新的刷新令牌 + expires_in: data.expires_in, + expiry_time: getCurrentTimestamp() + data.expires_in + }, + data.expires_in - 300 // 令牌过期前5分钟 + ); + + // 更新数据库中的刷新令牌 + if (data.refresh_token) { + await this.accountTokenModel.updateOne( + { accountId: accountId, platform: TokenPlatform.TIKTOK }, + { + refreshToken: data.refresh_token, + expiresAt: new Date((getCurrentTimestamp() + data.expires_in) * 1000), + updateTime: new Date() + } + ); + } + + this.logger.log('刷新TikTok访问令牌成功'); + // 返回系统令牌用于前端重定向 + const userInfo = await this.userModel.findOne({_id: userId}); + const systemTokenInfo = { + phone: userInfo?.phone ?? '', + id: userId, + name: userInfo.name, + isManager: false, + googleId: userInfo?.googleAccount?.googleId ?? '' + }; + + const systemToken = await this.authService.generateToken(systemTokenInfo); + return { url: systemToken }; + } catch (err) { + this.logger.error('刷新TikTok访问令牌失败:', err.response?.data || err.message); + throw new HttpException( + err.response?.data?.error_description || '刷新令牌失败', + err.response?.status || HttpStatus.BAD_REQUEST + ); + } + } + + /** + * 获取用户的TikTok访问令牌 + * @param accountId 账号ID + * @returns 访问令牌 + */ + async getUserAccessToken(accountId: string): Promise { + this.logger.log(`获取TikTok访问令牌: accountId=${accountId}`); + + // 先检查Redis缓存 + const cachedToken = await this.redisService.get(`tiktok:accessToken:${accountId}`); + if (cachedToken && cachedToken.access_token) { + this.logger.log("从Redis获取到有效令牌"); + return cachedToken.access_token; + } + + // 如果缓存中没有,尝试刷新 + const accountTokenInfo = await this.accountTokenModel.findOne({ + accountId: accountId, + platform: TokenPlatform.TIKTOK + }); + + if (!accountTokenInfo || !accountTokenInfo.refreshToken) { + throw new BadRequestException('无效的账号或刷新令牌丢失'); + } + + // 刷新并获取新令牌 + const refreshResult = await this.refreshAccessToken( + accountTokenInfo.userId, + accountTokenInfo.accountId, + accountTokenInfo.refreshToken + ); + + // 刷新后再次从Redis获取 + const newToken = await this.redisService.get(`tiktok:accessToken:${accountId}`); + if (!newToken || !newToken.access_token) { + throw new BadRequestException('刷新令牌后未能获取访问令牌'); + } + + return newToken.access_token; + } + + /** + * 检查用户是否已授权TikTok + * @param accountId 账号ID + * @returns 是否已授权 + */ + async isAuthorized(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + return !!accessToken; + } catch (error) { + return false; + } + } + + /** + * 撤销TikTok授权 + * @param accountId 账号ID + * @returns 撤销结果 + */ + async revokeAuthorization(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + if (!accessToken) { + return true; // 已经没有授权了 + } + + // 撤销TikTok令牌 + const params = new URLSearchParams({ + access_token: accessToken, + client_key: this.clientId, + client_secret: this.clientSecret + }); + + await firstValueFrom( + this.httpService.post(`${this.revokeUrl}`, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }) + ); + + // 删除Redis中的令牌 + await this.redisService.del(`tiktok:accessToken:${accountId}`); + + // 更新数据库记录状态 + await this.accountTokenModel.updateOne( + { accountId: accountId, platform: TokenPlatform.TIKTOK }, + { status: TokenStatus.DISABLE, updateTime: new Date() } + ); + + await this.accountModel.updateOne( + { uid: accountId, type: AccountType.TIKTOK }, + { status: AccountStatus.DISABLE, updateTime: new Date() } + ); + + return true; + } catch (error) { + this.logger.error('撤销TikTok授权失败:', error); + return false; + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.controller.ts new file mode 100644 index 000000000..c229d76df --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.controller.ts @@ -0,0 +1,547 @@ +import { Controller, Get, Post, Body, Query, Res, Param, Delete, UseInterceptors, BadRequestException, + UploadedFile, HttpException, HttpStatus, UseGuards } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiTags, ApiOperation, ApiBody, ApiConsumes, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { Express } from 'express'; +import { TikTokService } from './tiktok.service'; +import { TikTokAuthService } from './tiktok.auth.service'; +import { GetToken, Public, AuthGuard } from 'src/auth/auth.guard'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { CreateVideoDto, TikTokCommentDto, GetVideosQueryDto, TikTokVideoFilterDto, CombinedVideoUploadDto } from './dto/tiktok.dto'; +import { Response } from 'express'; + +@ApiTags('plat/tiktok - TikTok 平台') +@Controller('plat/tiktok') +// @UseGuards(AuthGuard) +export class TikTokController { + constructor( + private readonly tikTokService: TikTokService, + private readonly tikTokAuthService: TikTokAuthService, + ) {} + + /** + * 获取TikTok授权URL + */ + @Get('auth/url') + @ApiOperation({ summary: '获取TikTok授权URL' }) + @ApiQuery({ name: 'mail', required: false, description: '用户邮箱' }) + async getAuthUrl( + @GetToken() systemToken: TokenInfo, + @Query('mail') mail: string, + ) { + if (!systemToken.id || !mail) { + throw new BadRequestException('token和mail是必须的'); + } + return this.tikTokAuthService.getAuthorizationUrl(systemToken.id, mail); + } + + /** + * TikTok OAuth2回调处理 + */ + + @ApiOperation({ summary: 'TikTok OAuth2回调处理' }) + @Public() + @ApiQuery({ name: 'code', type: String, description: 'OAuth2授权码' }) + @ApiQuery({ name: 'state', type: String, description: '状态码' }) + @Get('auth/callback') + async handleAuthCallback( + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response, + ) { + if (!code || !state) { + throw new BadRequestException('缺少必要的参数'); + } + console.log(code, state); + + try { + // 处理授权回调 + const results = await this.tikTokAuthService.handleAuthorizationCallback(code, state); + const render_msg = { + message: "授权成功! 这里是添加账号成功后的前端页面," , + datas: results + }; + + return res.render('google/index', render_msg); + } catch (error) { + console.error('处理TikTok授权回调失败:', error.response?.data || error.message); + throw new BadRequestException(`处理授权回调失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 检查用户是否已授权TikTok + */ + @Get('auth/check') + @ApiOperation({ summary: '检查用户是否已授权TikTok' }) + @ApiQuery({ name: 'accountId', type: String, description: 'TikTok账号ID' }) + async checkAuth(@Query('accountId') accountId: string) { + if (!accountId) { + throw new BadRequestException('accountId是必须的'); + } + + const isAuthorized = await this.tikTokAuthService.isAuthorized(accountId); + return { authorized: isAuthorized }; + } + + /** + * 撤销TikTok授权 + */ + @Post('auth/revoke') + @ApiOperation({ summary: '撤销TikTok授权' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' } + }, + required: ['accountId'] + } + }) + async revokeAuth(@GetToken() systemToken: TokenInfo, @Body('accountId') accountId: string) { + if (!accountId) { + throw new BadRequestException('accountId是必须的'); + } + + const result = await this.tikTokAuthService.revokeAuthorization(accountId); + return { success: result }; + } + + /** + * 获取用户视频列表 + */ + @Get('videos/list') + @ApiOperation({ summary: '获取用户视频列表' }) + async getUserVideos( + @GetToken() systemToken: TokenInfo, + @Query() queryDto: GetVideosQueryDto + ) { + const userId = systemToken.id; + if (!queryDto.accountId) { + throw new BadRequestException('accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(queryDto.accountId); + return this.tikTokService.getUserVideos( + accessToken, + userId, + queryDto.accountId, + queryDto.limit, + queryDto.cursor + ); + } + + /** + * 获取视频详情 + */ + @Get('videos/detail') + @ApiOperation({ summary: '获取视频详情' }) + @ApiQuery({ name: 'videoId', description: '视频ID' }) + @ApiQuery({ name: 'accountId', description: 'TikTok账号ID' }) + async getVideoDetail( + @Query('videoId') videoId: string, + @Query('accountId') accountId: string + ) { + if (!videoId || !accountId) { + throw new BadRequestException('videoId和accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.getVideoDetail(accessToken, videoId); + } + + /** + * 上传视频 + */ + @Post('videos/upload') + @ApiOperation({ summary: '上传视频文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string' }, + file: { + type: 'string', + format: 'binary', + description: '视频文件' + } + }, + required: ['accountId', 'file'] + } + }) + @UseInterceptors(FileInterceptor('file')) + async uploadVideo( + @GetToken() systemToken: TokenInfo, + @Body('accountId') accountId: string, + @UploadedFile() file: Express.Multer.File + ) { + if (!accountId || !file) { + throw new BadRequestException('accountId和视频文件是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.uploadVideo(accessToken, file.buffer); + } + + /** + * 发布视频 + */ + @Post('videos/publish') + @ApiOperation({ summary: '发布视频' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + description: { type: 'string', description: '视频描述' }, + videoId: { type: 'string', description: '上传后的视频ID' }, + private: { type: 'boolean', description: '是否为私有视频' }, + hashtags: { + type: 'array', + items: { type: 'string' }, + description: '话题标签列表' + } + }, + required: ['accountId', 'description', 'videoId'] + } + }) + async publishVideo( + @GetToken() systemToken: TokenInfo, + @Body() createVideoDto: CreateVideoDto, + @Body('videoId') videoId: string + ) { + const userId = systemToken.id; + if (!createVideoDto.accountId || !videoId) { + throw new BadRequestException('accountId和videoId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(createVideoDto.accountId); + return this.tikTokService.publishVideo( + accessToken, + userId, + createVideoDto.accountId, + createVideoDto, + videoId + ); + } + + /** + * 删除视频 + */ + @Post('videos/delete') + @ApiOperation({ summary: '删除视频' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + videoId: { type: 'string', description: '视频ID' } + }, + required: ['accountId', 'videoId'] + } + }) + async deleteVideo( + @GetToken() systemToken: TokenInfo, + @Body('videoId') videoId: string, + @Body('accountId') accountId: string + ) { + if (!videoId || !accountId) { + throw new BadRequestException('videoId和accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.deleteVideo(accessToken, videoId); + } + + /** + * 获取视频评论 + */ + @Get('videos/:videoId/comments') + @ApiOperation({ summary: '获取视频评论' }) + @ApiParam({ name: 'videoId', description: '视频ID' }) + @ApiQuery({ name: 'accountId', description: 'TikTok账号ID' }) + @ApiQuery({ name: 'limit', description: '每页结果数', required: false }) + @ApiQuery({ name: 'cursor', description: '分页游标', required: false }) + async getVideoComments( + @Param('videoId') videoId: string, + @Query('accountId') accountId: string, + @Query('limit') limit?: number, + @Query('cursor') cursor?: string + ) { + if (!videoId || !accountId) { + throw new BadRequestException('videoId和accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.getVideoComments(accessToken, videoId, limit, cursor); + } + + /** + * 发表评论 + */ + @Post('videos/comment') + @ApiOperation({ summary: '发表评论' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + videoId: { type: 'string', description: '视频ID' }, + text: { type: 'string', description: '评论内容' } + }, + required: ['accountId', 'videoId', 'text'] + } + }) + async postComment( + @GetToken() systemToken: TokenInfo, + @Body() commentDto: TikTokCommentDto + ) { + if (!commentDto.accountId || !commentDto.videoId || !commentDto.text) { + throw new BadRequestException('accountId, videoId和text是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(commentDto.accountId); + return this.tikTokService.postComment(accessToken, commentDto); + } + + /** + * 删除评论 + */ + @Post('videos/comment/delete') + @ApiOperation({ summary: '删除评论' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + videoId: { type: 'string', description: '视频ID' }, + commentId: { type: 'string', description: '评论ID' } + }, + required: ['accountId', 'videoId', 'commentId'] + } + }) + async deleteComment( + @GetToken() systemToken: TokenInfo, + @Body('accountId') accountId: string, + @Body('videoId') videoId: string, + @Body('commentId') commentId: string + ) { + if (!accountId || !videoId || !commentId) { + throw new BadRequestException('accountId, videoId和commentId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.deleteComment(accessToken, videoId, commentId); + } + + /** + * 视频点赞或取消点赞 + */ + @Post('videos/rate') + @ApiOperation({ summary: '视频点赞或取消点赞' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + videoId: { type: 'string', description: '视频ID' }, + rating: { type: 'string', description: '操作类型: like 或 unlike' } + }, + required: ['accountId', 'videoId', 'rating'] + } + }) + async rateVideo( + @GetToken() systemToken: TokenInfo, + @Body('videoId') videoId: string, + @Body('accountId') accountId: string, + @Body('rating') rating: string + ) { + const userId = systemToken.id; + if (!videoId || !userId || !accountId) { + throw new BadRequestException('videoId, userId和accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + + // 在方法开始处验证 + if (!["like", "unlike"].includes(rating)) { + throw new BadRequestException('参数错误: rating必须为like或unlike'); + } + + // 后续处理 + if (rating === "like") { + return this.tikTokService.likeVideo(accessToken, userId, accountId, videoId); + } else { + return this.tikTokService.unlikeVideo(accessToken, userId, accountId, videoId); + } + } + + /** + * 搜索TikTok视频 + */ + @Get('search') + @ApiOperation({ summary: '搜索TikTok视频' }) + async searchVideos( + @GetToken() systemToken: TokenInfo, + @Query() filterDto: TikTokVideoFilterDto + ) { + if (!filterDto.accountId) { + throw new BadRequestException('accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(filterDto.accountId); + return this.tikTokService.searchVideos(accessToken, filterDto); + } + + /** + * 初始化视频发布(新版API) + */ + @Post('videos/init-publish') + @ApiOperation({ summary: '初始化视频发布(新版API)' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + title: { type: 'string', description: '视频标题' }, + description: { type: 'string', description: '视频描述' }, + privacyLevel: { type: 'string', description: '隐私级别', enum: ['PUBLIC', 'PRIVATE', 'FRIENDS'], default: 'PUBLIC' }, + disableComment: { type: 'boolean', description: '是否禁用评论', default: false }, + disableDuet: { type: 'boolean', description: '是否禁用二重奏', default: false }, + disableStitch: { type: 'boolean', description: '是否禁用Stitch', default: false }, + videoCoverTimestampMs: { type: 'number', description: '视频封面时间点(毫秒)' }, + hashtags: { type: 'array', items: { type: 'string' }, description: '话题标签列表' }, + videoSize: { type: 'number', description: '视频大小(字节)' } + }, + required: ['accountId', 'videoSize'] + } + }) + async initVideoPublish( + @GetToken() systemToken: TokenInfo, + @Body('accountId') accountId: string, + @Body('videoSize') videoSize: number, + @Body() videoInfo: { + title?: string; + description?: string; + privacyLevel?: string; + disableComment?: boolean; + disableDuet?: boolean; + disableStitch?: boolean; + videoCoverTimestampMs?: number; + hashtags?: string[]; + } + ) { + if (!accountId || !videoSize) { + throw new BadRequestException('accountId和视频大小是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.initVideoPublish(accessToken, videoSize, videoInfo); + } + + /** + * 检查视频发布状态(新版API) + */ + @Post('videos/publish/status') + @ApiOperation({ summary: '检查视频发布状态' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + publishId: { type: 'string', description: '发布ID' } + }, + required: ['accountId', 'publishId'] + } + }) + async checkPublishStatus( + @GetToken() systemToken: TokenInfo, + @Body('accountId') accountId: string, + @Body('publishId') publishId: string + ) { + if (!accountId || !publishId) { + throw new BadRequestException('accountId和publishId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.checkPublishStatus(accessToken, publishId); + } + + /** + * 一键上传并发布视频(新版API三步法) + */ + @Post('videos/publish') + @ApiOperation({ summary: '一键上传并发布视频(新版API)' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'TikTok账号ID' }, + title: { type: 'string', description: '视频标题' }, + description: { type: 'string', description: '视频描述' }, + privacyStatus: { type: 'string', description: '隐私级别', enum: ['PUBLIC', 'PRIVATE', 'FRIENDS'], default: 'PUBLIC' }, + disableComment: { type: 'boolean', description: '是否禁用评论', default: false }, + disableDuet: { type: 'boolean', description: '是否禁用二重奏', default: false }, + disableStitch: { type: 'boolean', description: '是否禁用Stitch', default: false }, + videoCoverTimestampMs: { type: 'number', description: '视频封面时间点(毫秒)' }, + hashtags: { type: 'array', items: { type: 'string' }, description: '话题标签列表' }, + pollInterval: { type: 'number', description: '轮询间隔(毫秒)', default: 2000 }, + maxRetries: { type: 'number', description: '最大重试次数', default: 30 }, + file: { type: 'string', format: 'binary', description: '视频文件' } + }, + required: ['accountId', 'file'] + } + }) + @UseInterceptors(FileInterceptor('file')) + async uploadAndPublishVideo( + @GetToken() systemToken: TokenInfo, + @Body() uploadDto: CombinedVideoUploadDto, + @UploadedFile() file: Express.Multer.File + ) { + const userId = systemToken.id; + if (!uploadDto.accountId || !file) { + throw new BadRequestException('accountId和视频文件是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(uploadDto.accountId); + return this.tikTokService.uploadAndPublishVideo( + accessToken, + userId, + uploadDto.accountId, + file.buffer, + { + title: uploadDto.title, + description: uploadDto.description, + privacyStatus: uploadDto.privacyStatus || 'PUBLIC', + disableComment: uploadDto.disableComment || false, + disableDuet: uploadDto.disableDuet || false, + disableStitch: uploadDto.disableStitch || false, + videoCoverTimestampMs: uploadDto.videoCoverTimestampMs, + tags: uploadDto.tags + }, + uploadDto.pollInterval, + uploadDto.maxRetries + ); + } + + /** + * 获取TikTok账号信息 + */ + @Get('user/profile') + @ApiOperation({ summary: '获取TikTok账号信息' }) + @ApiQuery({ name: 'accountId', description: 'TikTok账号ID' }) + async getUserProfile( + @GetToken() systemToken: TokenInfo, + @Query('accountId') accountId: string + ) { + if (!accountId) { + throw new BadRequestException('accountId是必须的'); + } + + const accessToken = await this.tikTokAuthService.getUserAccessToken(accountId); + return this.tikTokService.getUserProfile(accessToken, accountId); + } + +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.module.ts new file mode 100644 index 000000000..0d85e2f8b --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.module.ts @@ -0,0 +1,47 @@ +/* + * @Author: + * @Date: 2025-06-06 + * @LastEditTime: 2025-06-06 + * @Description: TikTok模块 + */ +import { Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { MongooseModule } from '@nestjs/mongoose'; + +import { TikTokController } from './tiktok.controller'; +import { TikTokService } from './tiktok.service'; +import { TikTokAuthService } from './tiktok.auth.service'; +import { PubRecord, PubRecordSchema } from 'src/db/schema/pubRecord.schema'; +import tiktokConfig from 'config/tiktok.config'; +import { User, UserSchema } from 'src/db/schema/user.schema'; +import { Account, AccountSchema } from 'src/db/schema/account.schema'; +import { AccountToken, AccountTokenSchema } from 'src/db/schema/accountToken.schema'; +import { UserModule } from 'src/user/user.module'; +import { RedisModule } from 'src/lib/redis/redis.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { AccountModule } from 'src/modules/account/account.module'; +import { forwardRef } from '@nestjs/common'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [tiktokConfig], + }), + HttpModule, + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: Account.name, schema: AccountSchema }, + { name: AccountToken.name, schema: AccountTokenSchema }, + { name: PubRecord.name, schema: PubRecordSchema }, + ]), + UserModule, + RedisModule, + forwardRef(() => AuthModule), + forwardRef(() => AccountModule), + ], + controllers: [TikTokController], + providers: [TikTokService, TikTokAuthService], + exports: [TikTokService, TikTokAuthService] +}) +export class TiktokModule { } diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.service.ts new file mode 100644 index 000000000..c7c85eed2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/tiktok/tiktok.service.ts @@ -0,0 +1,991 @@ +import { Injectable, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { firstValueFrom } from 'rxjs'; +import * as FormData from 'form-data'; + +import { PubRecord, PubStatus, PubType } from 'src/db/schema/pubRecord.schema'; +import { TikTokAuthService } from './tiktok.auth.service'; +import { CreateVideoDto, TikTokCommentDto, GetVideosQueryDto, TikTokVideoFilterDto } from './dto/tiktok.dto'; + +@Injectable() +export class TikTokService { + private readonly logger = new Logger(TikTokService.name); + private readonly apiBaseUrl: string; + private readonly uploadApiBaseUrl: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly tikTokAuthService: TikTokAuthService, + @InjectModel(PubRecord.name) private readonly pubRecordModel: Model, + ) { + const tiktokConfig = this.configService.get('tiktok'); + if (!tiktokConfig) { + throw new Error('TikTok配置未找到,请检查环境变量和配置文件'); + } + this.apiBaseUrl = tiktokConfig.apiBaseUrl; + this.uploadApiBaseUrl = tiktokConfig.uploadApiBaseUrl; + } + + /** + * 获取用户视频列表 + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId TikTok账号ID + * @param limit 每页结果数 + * @param cursor 分页游标 + * @returns 用户视频列表 + */ + async getUserVideos( + accessToken: string, + userId: string, + accountId: string, + limit = 10, + cursor?: string + ): Promise { + try { + // 确保limit是数字且在有效范围内 + limit = isNaN(Number(limit)) ? 10 : Math.min(Math.max(Number(limit), 1), 50); + + const params: any = { + fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,embed_html,thumbnail_url', + max_count: limit + }; + + if (cursor) { + params.cursor = cursor; + } + + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/video/list`, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data; + } catch (error) { + this.logger.error('获取TikTok视频列表失败:', error.response?.data || error.message); + throw new BadRequestException(`获取视频列表失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 获取视频详情 + * @param accessToken 访问令牌 + * @param videoId 视频ID + * @returns 视频详情 + */ + async getVideoDetail( + accessToken: string, + videoId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/video/info/`, { + params: { + fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,embed_html,thumbnail_url', + video_id: videoId + }, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data; + } catch (error) { + this.logger.error('获取TikTok视频详情失败:', error.response?.data || error.message); + throw new BadRequestException(`获取视频详情失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 方式1:初始化视频上传(旧版) + * @param accessToken 访问令牌 + * @param videoSize 视频文件总大小(字节) + * @param chunkSize 分片大小(字节),默认为5MB + * @returns 初始化结果,包含上传所需的参数 + */ + async initVideoUpload( + accessToken: string, + videoSize?: number, + chunkSize: number = 5 * 1024 * 1024 // 默认5MB + ): Promise { + try { + const requestBody: any = {}; + + // 如果提供了视频大小,添加分片上传的相关信息 + if (videoSize) { + const totalChunkCount = Math.ceil(videoSize / chunkSize); + requestBody.source_info = { + source: "FILE_UPLOAD", + video_size: videoSize, + chunk_size: chunkSize, + total_chunk_count: totalChunkCount + }; + } + + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/post/publish/inbox/video/init/`, requestBody, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data; + } catch (error) { + this.logger.error('初始化TikTok视频上传失败:', error.response?.data || error.message); + throw new BadRequestException(`初始化视频上传失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 方式2:初始化视频发布(直接发布,新版) + * @param accessToken 访问令牌 + * @param videoSize 视频文件总大小(字节) + * @param videoInfo 视频相关信息,包含标题、隐私级别等 + * @param chunkSize 分片大小(字节),默认为10MB + * @returns 初始化结果,包含上传所需的参数 + */ + async initVideoPublish( + accessToken: string, + videoSize: number, + videoInfo: { + title?: string; + description?: string; + privacyStatus?: string; + disableComment?: boolean; + disableDuet?: boolean; + disableStitch?: boolean; + videoCoverTimestampMs?: number; + tags?: string[]; + }, + chunkSize: number = 10 * 1024 * 1024 // 默认10MB + ): Promise { + try { + // 计算总分片数 + const totalChunkCount = Math.ceil(videoSize / chunkSize); + + // 处理hashtags,如果提供了 + let title = videoInfo.title || videoInfo.description || ''; + if (videoInfo.tags && videoInfo.tags.length > 0) { + const hashtagText = videoInfo.tags + .map(tag => `#${tag.replace(/^#/, '')}`) + .join(' '); + if (title) { + title = `${title} ${hashtagText}`; + } else { + title = hashtagText; + } + } + + // 构建请求体 + const requestBody: any = { + post_info: { + title: title, + privacy_level: videoInfo.privacyStatus || 'PUBLIC', + disable_duet: videoInfo.disableDuet || false, + disable_comment: videoInfo.disableComment || false, + disable_stitch: videoInfo.disableStitch || false + }, + source_info: { + source: "FILE_UPLOAD", + video_size: videoSize, + chunk_size: chunkSize, + total_chunk_count: totalChunkCount + } + }; + + // 添加视频封面时间戳,如果提供了 + if (videoInfo.videoCoverTimestampMs) { + requestBody.post_info.video_cover_timestamp_ms = videoInfo.videoCoverTimestampMs; + } + + this.logger.debug('初始化视频发布请求:', JSON.stringify(requestBody)); + + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/post/publish/video/init/`, requestBody, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + if (!data.data.publish_id || !data.data.upload_url) { + throw new BadRequestException('初始化视频发布失败,缺少publish_id或upload_url'); + } + + return data.data; + } catch (error) { + this.logger.error('初始化TikTok视频发布失败:', error.response?.data || error.message); + throw new BadRequestException(`初始化视频发布失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 上传视频文件 + * @param accessToken 访问令牌 + * @param videoBuffer 视频文件缓冲区 + * @param initData 初始化返回的数据 + * @returns 上传结果 + */ + async uploadVideo( + accessToken: string, + videoBuffer: Buffer, + initData?: any + ): Promise { + try { + // 如果没有提供初始化数据,先进行初始化 + if (!initData) { + // 计算视频大小并进行初始化 + const videoSize = videoBuffer.length; + initData = await this.initVideoUpload(accessToken, videoSize); + } + + // 检查是否需要分片上传 + if (initData.publish_id && initData.upload_url && videoBuffer.length > 10 * 1024 * 1024) { + // 如果有publish_id和upload_url,并且视频超过10MB,则使用分片上传 + return await this.uploadVideoChunked(accessToken, videoBuffer, initData); + } + + // 如果不需要分片,使用单次上传 + // 创建Node.js版的FormData + const formData = new FormData(); + + // 直接将Buffer添加到FormData中 + const filename = `video_${Date.now()}.mp4`; + formData.append('video', videoBuffer, { + filename, + contentType: 'video/mp4' + }); + + // 添加初始化返回的必要参数 + if (initData.upload_params) { + Object.entries(initData.upload_params).forEach(([key, value]) => { + formData.append(key, value); + }); + } + + // 使用初始化返回的上传URL,如果没有则使用默认URL + const uploadUrl = initData.upload_url || `${this.apiBaseUrl}/v2/video/upload/`; + + const { data } = await firstValueFrom( + this.httpService.post(uploadUrl, formData, { + headers: { + ...formData.getHeaders(), + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { + ...data.data, + init_data: initData, // 返回初始化数据,可能在发布时需要 + }; + } catch (error) { + this.logger.error('上传TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`上传视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 方式1:分片上传视频(POST方式) + * @param accessToken 访问令牌 + * @param videoBuffer 视频文件缓冲区 + * @param initData 初始化返回的数据,包含 publish_id 和 upload_url + * @returns 上传结果 + */ + private async uploadVideoChunked( + accessToken: string, + videoBuffer: Buffer, + initData: any + ): Promise { + try { + const { publish_id, upload_url } = initData; + + if (!publish_id || !upload_url) { + throw new BadRequestException('初始化响应缺少 publish_id 或 upload_url'); + } + + // 设置分片大小(每个分片5MB) + const chunkSize = 5 * 1024 * 1024; + const totalSize = videoBuffer.length; + const totalChunkCount = Math.ceil(totalSize / chunkSize); + + this.logger.debug(`视频总大小: ${totalSize} 字节, 分片数: ${totalChunkCount}`); + + // 选择视频内容类型 + const contentType = 'video/mp4'; + + // 存储每个分片的上传响应 + const uploadResponses = []; + + // 上传每个分片 + for (let i = 0; i < totalChunkCount; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, totalSize); + const chunkBuffer = videoBuffer.subarray(start, end); + const chunkLength = chunkBuffer.length; + + this.logger.debug(`正在上传第${i + 1}/${totalChunkCount}个分片,范围: ${start}-${end-1}/${totalSize}, 大小: ${chunkLength} 字节`); + + // 使用原生的请求头而不是FormData + const { data } = await firstValueFrom( + this.httpService.post(upload_url, chunkBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': chunkLength.toString(), + 'Content-Range': `bytes ${start}-${end-1}/${totalSize}`, + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + uploadResponses.push(data); + } + + // 使用最后一个分片的响应作为最终响应 + const finalResponse = uploadResponses[totalChunkCount - 1]; + + return { + ...finalResponse.data, + publish_id, + video_id: finalResponse.data?.video_id || finalResponse.data?.id, + init_data: initData, // 返回初始化数据,可能在发布时需要 + }; + } catch (error) { + this.logger.error('分片上传TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`分片上传视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 方式2:直接上传视频(PUT方式) + * @param accessToken 访问令牌 + * @param videoBuffer 视频文件缓冲区 + * @param initData 初始化返回的数据,包含 publish_id 和 upload_url + * @returns 上传结果 + */ + async directUploadVideo( + accessToken: string, + videoBuffer: Buffer, + initData: any + ): Promise { + try { + const { publish_id, upload_url } = initData; + + if (!publish_id || !upload_url) { + throw new BadRequestException('初始化响应缺少 publish_id 或 upload_url'); + } + + const totalSize = videoBuffer.length; + // 建议的分片大小,如果文件大于10MB则分片上传 + const chunkSize = 10 * 1024 * 1024; + const needChunking = totalSize > chunkSize; + + // 选择视频内容类型 + const contentType = 'video/mp4'; + + this.logger.debug(`直接上传视频,总大小: ${totalSize} 字节, 是否需要分片: ${needChunking}`); + + if (!needChunking) { + // 单次上传整个文件 + const { data } = await firstValueFrom( + this.httpService.put(upload_url, videoBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': totalSize.toString(), + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { + publish_id, + ...data?.data, + }; + } else { + // 分片上传 + const totalChunkCount = Math.ceil(totalSize / chunkSize); + let lastResponse = null; + + // 上传每个分片 + for (let i = 0; i < totalChunkCount; i++) { + const start = i * chunkSize; + const end = Math.min(start + chunkSize, totalSize); + const chunkBuffer = videoBuffer.subarray(start, end); + const chunkLength = chunkBuffer.length; + + this.logger.debug(`正在上传第${i + 1}/${totalChunkCount}个分片,范围: ${start}-${end-1}/${totalSize}, 大小: ${chunkLength} 字节`); + + const { data } = await firstValueFrom( + this.httpService.put(upload_url, chunkBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': chunkLength.toString(), + 'Content-Range': `bytes ${start}-${end-1}/${totalSize}`, + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + lastResponse = data; + } + + return { + publish_id, + ...lastResponse?.data, + }; + } + } catch (error) { + this.logger.error('直接上传TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`直接上传视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 检查视频发布状态 + * @param accessToken 访问令牌 + * @param publishId 发布ID + * @returns 发布状态信息 + */ + async checkPublishStatus( + accessToken: string, + publishId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post( + `${this.apiBaseUrl}/v2/post/publish/status/fetch/`, + { publish_id: publishId }, + { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + } + ) + ); + + return data.data; + } catch (error) { + this.logger.error('检查TikTok视频发布状态失败:', error.response?.data || error.message); + throw new BadRequestException(`检查视频发布状态失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 三步式完整上传并发布视频(新版API) + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId TikTok账号ID + * @param videoBuffer 视频数据 + * @param videoInfo 视频信息 + * @param pollInterval 轮询状态的时间间隔(毫秒)。默认2秒。 + * @param maxRetries 最大重试次数。默认30次,大约60秒。 + * @returns 视频发布结果 + */ + async uploadAndPublishVideo( + accessToken: string, + userId: string, + accountId: string, + videoBuffer: Buffer, + videoInfo: { + title?: string; + description?: string; + privacyStatus?: string; + disableComment?: boolean; + disableDuet?: boolean; + disableStitch?: boolean; + videoCoverTimestampMs?: number; + tags?: string[]; + }, + pollInterval: number = 2000, + maxRetries: number = 30 + ): Promise { + try { + // 1. 第一步:初始化视频发布 + this.logger.debug('第一步:初始化视频发布...'); + const videoSize = videoBuffer.length; + const initResult = await this.initVideoPublish(accessToken, videoSize, videoInfo); + + if (!initResult.publish_id || !initResult.upload_url) { + throw new BadRequestException('初始化失败,缺少必要的上传参数'); + } + + const { publish_id } = initResult; + + // 2. 第二步:上传视频文件 + this.logger.debug(`第二步:上传视频文件,publish_id: ${publish_id}...`); + await this.directUploadVideo(accessToken, videoBuffer, initResult); + + // 3. 第三步:轮询视频发布状态 // 每分钟不超过30次 + this.logger.debug(`第三步:轮询视频发布状态,publish_id: ${publish_id}...`); + + // 存储发布记录 + const maxRecord = await this.pubRecordModel.findOne().sort({ id: -1 }); + const newId = maxRecord ? maxRecord.id + 1 : 1; + + const pubRecord = await this.pubRecordModel.create({ + id: newId, + userId, + accountId, + type: PubType.VIDEO, + status: PubStatus.UNPUBLISH, + title: videoInfo.title, + desc: videoInfo.description, + attachments: [publish_id], + publishTime: new Date(), + createTime: new Date(), + updateTime: new Date(), + }); + + let finalStatus = null; + let tries = 0; + + // 轮询检查发布状态 + while (tries < maxRetries) { + tries++; + + const statusResult = await this.checkPublishStatus(accessToken, publish_id); + this.logger.debug(`状态检查 ${tries}/${maxRetries}: ${JSON.stringify(statusResult)}`); + + // 判断视频是否发布成功 + // 根据实际API返回的状态字段来判断(这里的status字段可能需要根据实际情况调整) + if (statusResult.status === 'SUCCESS' || statusResult.status === 'PUBLISHED') { + finalStatus = statusResult; + break; + } else if (statusResult.status === 'FAILED' || statusResult.status === 'ERROR') { + // 更新发布记录为失败状态 + await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, { + status: PubStatus.FAIL, + failReason: statusResult.error_message || '发布失败', + updateTime: new Date() + }); + + throw new BadRequestException(`视频发布失败: ${statusResult.error_message || '未知错误'}`); + } + + // 等待指定时间后再次查询 + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + if (!finalStatus) { + // 超时仍未完成 + await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, { + status: PubStatus.FAIL, + failReason: '检查视频发布状态超时', + updateTime: new Date() + }); + + throw new BadRequestException('视频发布状态检查超时,请稍后在TikTok应用中查看发布状态'); + } + + // 更新发布记录为成功状态 + const videoId = finalStatus.video_id || finalStatus.id || publish_id; + await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, { + status: PubStatus.RELEASED, + resourceId: videoId, + updateTime: new Date() + }); + + return { + ...finalStatus, + resourceId: videoId, + publish_id, + }; + } catch (error) { + this.logger.error('三步式视频上传发布失败:', error.response?.data || error.message); + throw new BadRequestException(`视频上传发布失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 发布视频(原始方式) + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId TikTok账号ID + * @param videoDto 视频发布参数 + * @param uploadResult 上传结果,包含视频ID和初始化数据 + * @returns 发布结果 + */ + async publishVideo( + accessToken: string, + userId: string, + accountId: string, + videoDto: CreateVideoDto, + uploadResult: any + ): Promise { + try { + const videoId = uploadResult.video_id || (uploadResult.init_data?.video_id); + if (!videoId) { + throw new BadRequestException('无效的视频上传结果,缺少视频ID'); + } + + const params: any = { + video_id: videoId, + text: videoDto.description, + disable_comment: false, + disable_duet: false, + privacy_level: videoDto.private ? 'private' : 'public' + }; + + if (videoDto.hashtags && videoDto.hashtags.length > 0) { + // 添加话题标签 + const hashtags = videoDto.hashtags.map(tag => `#${tag.replace(/^#/, '')}`).join(' '); + params.text = `${params.text} ${hashtags}`; + } + + // 如果有初始化数据,添加必要的发布参数 + if (uploadResult.init_data && uploadResult.init_data.publish_params) { + Object.assign(params, uploadResult.init_data.publish_params); + } + + // 获取当前最大的 id + const maxRecord = await this.pubRecordModel.findOne().sort({ id: -1 }); + const newId = maxRecord ? maxRecord.id + 1 : 1; + + // 创建发布记录 + const pubRecord = await this.pubRecordModel.create({ + id: newId, + userId, + accountId, + type: PubType.VIDEO, + status: PubStatus.UNPUBLISH, + content: videoDto.description, + attachments: [videoId], + publishTime: new Date(), + createTime: new Date(), + updateTime: new Date(), + }); + + try { + // 获取发布URL,可能在初始化时已提供 + const publishUrl = (uploadResult.init_data && uploadResult.init_data.publish_url) || + `${this.apiBaseUrl}/v2/video/publish/`; + + const { data } = await firstValueFrom( + this.httpService.post(publishUrl, params, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + // 更新发布记录状态 + await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, { + status: PubStatus.RELEASED, + remoteId: data.data.share_id || videoId, + updateTime: new Date() + }); + + return data.data; + } catch (error) { + // 更新发布记录为失败状态 + await this.pubRecordModel.findByIdAndUpdate(pubRecord._id, { + status: PubStatus.FAIL, + failReason: error.response?.data?.error?.message || error.message, + updateTime: new Date() + }); + + throw error; + } + } catch (error) { + this.logger.error('发布TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`发布视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 删除视频 + * @param accessToken 访问令牌 + * @param videoId 视频ID + * @returns 删除结果 + */ + async deleteVideo( + accessToken: string, + videoId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/video/delete/`, { + video_id: videoId + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { success: true }; + } catch (error) { + this.logger.error('删除TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`删除视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 获取视频评论列表 + * @param accessToken 访问令牌 + * @param videoId 视频ID + * @param limit 每页结果数 + * @param cursor 分页游标 + * @returns 评论列表 + */ + async getVideoComments( + accessToken: string, + videoId: string, + limit = 20, + cursor?: string + ): Promise { + try { + // 确保limit是数字且在有效范围内 + limit = isNaN(Number(limit)) ? 20 : Math.min(Math.max(Number(limit), 1), 50); + + const params: any = { + fields: 'id,text,create_time,like_count,reply_comment_total,user', + video_id: videoId, + max_count: limit + }; + + if (cursor) { + params.cursor = cursor; + } + + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/comment/list/`, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data; + } catch (error) { + this.logger.error('获取TikTok视频评论失败:', error.response?.data || error.message); + throw new BadRequestException(`获取评论失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 发表评论 + * @param accessToken 访问令牌 + * @param commentDto 评论参数 + * @returns 评论结果 + */ + async postComment( + accessToken: string, + commentDto: TikTokCommentDto + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/comment/post/`, { + video_id: commentDto.videoId, + text: commentDto.text + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data; + } catch (error) { + this.logger.error('发表TikTok评论失败:', error.response?.data || error.message); + throw new BadRequestException(`发表评论失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 删除评论 + * @param accessToken 访问令牌 + * @param videoId 视频ID + * @param commentId 评论ID + * @returns 删除结果 + */ + async deleteComment( + accessToken: string, + videoId: string, + commentId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/comment/delete/`, { + video_id: videoId, + comment_id: commentId + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { success: true }; + } catch (error) { + this.logger.error('删除TikTok评论失败:', error.response?.data || error.message); + throw new BadRequestException(`删除评论失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 点赞视频 + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId TikTok账号ID + * @param videoId 视频ID + * @returns 点赞结果 + */ + async likeVideo( + accessToken: string, + userId: string, + accountId: string, + videoId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/video/like/`, { + video_id: videoId + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { success: true }; + } catch (error) { + this.logger.error('TikTok视频点赞失败:', error.response?.data || error.message); + throw new BadRequestException(`视频点赞失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 取消点赞视频 + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId TikTok账号ID + * @param videoId 视频ID + * @returns 取消点赞结果 + */ + async unlikeVideo( + accessToken: string, + userId: string, + accountId: string, + videoId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(`${this.apiBaseUrl}/v2/video/unlike/`, { + video_id: videoId + }, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return { success: true }; + } catch (error) { + this.logger.error('取消TikTok视频点赞失败:', error.response?.data || error.message); + throw new BadRequestException(`取消视频点赞失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 搜索视频 + * @param accessToken 访问令牌 + * @param filterDto 搜索过滤参数 + * @returns 搜索结果 + */ + async searchVideos( + accessToken: string, + filterDto: TikTokVideoFilterDto + ): Promise { + try { + // 确保limit是数字且在有效范围内 + const limit = isNaN(Number(filterDto.limit)) ? 10 : Math.min(Math.max(Number(filterDto.limit), 1), 50); + + const params: any = { + fields: 'id,create_time,video_description,duration,height,width,share_count,comment_count,like_count,view_count,title,embed_link,thumbnail_url', + max_count: limit, + search_key: filterDto.keyword || '' + }; + + if (filterDto.cursor) { + params.cursor = filterDto.cursor; + } + + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/video/search/`, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + // 如果设置了最低播放次数过滤 + let videos = data.data.videos || []; + if (filterDto.minPlayCount && !isNaN(Number(filterDto.minPlayCount)) && Number(filterDto.minPlayCount) > 0) { + videos = videos.filter(video => { + return video.view_count >= Number(filterDto.minPlayCount); + }); + } + + return { + videos: videos, + cursor: data.data.cursor, + has_more: data.data.has_more + }; + } catch (error) { + this.logger.error('搜索TikTok视频失败:', error.response?.data || error.message); + throw new BadRequestException(`搜索视频失败: ${error.response?.data?.error?.message || error.message}`); + } + } + + /** + * 获取用户TikTok账号信息 + * @param accessToken 访问令牌 + * @param accountId TikTok账号ID + * @returns 账号信息 + */ + async getUserProfile( + accessToken: string, + accountId: string + ): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.get(`${this.apiBaseUrl}/v2/user/info/`, { + params: { + fields: 'open_id,union_id,avatar_url,bio_description,profile_deep_link,is_verified,follower_count,following_count,likes_count,video_count,nickname', + open_id: accountId + }, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return data.data.user; + } catch (error) { + this.logger.error('获取TikTok用户信息失败:', error.response?.data || error.message); + throw new BadRequestException(`获取用户信息失败: ${error.response?.data?.error?.message || error.message}`); + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/comment.ts new file mode 100644 index 000000000..73a91e28a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/comment.ts @@ -0,0 +1,13 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface AccessToken { + access_token: string; + expires_in: number; + refresh_token: string; + scopes: string[]; + token_type: string; + id_token: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/dto/twitter.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/dto/twitter.dto.ts new file mode 100644 index 000000000..9c6408e52 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/dto/twitter.dto.ts @@ -0,0 +1,32 @@ +export interface TwitterOAuthTokenResponse { + token_type: 'bearer'; + expires_in: number; // typically 7200 seconds (2 hours) + access_token: string; + scope: string; // e.g., "users.read tweet.read offline.access" + refresh_token?: string; // Present if 'offline.access' scope was requested +} + +export interface TwitterUser { + id: string; + name: string; // Display name + username: string; // Handle + profile_image_url?: string; + description?: string; + public_metrics?: { + followers_count: number; + following_count: number; + tweet_count: number; + listed_count: number; + }; + created_at?: string; // ISO 8601 date string + verified?: boolean; + // Add other fields as needed from the Twitter API v2 user object +} + +// For storing in our AccountToken schema +export interface TwitterStoredTokenInfo { + accessToken: string; + refreshToken?: string; + expiresAt: Date; + scopes: string[]; +} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.auth.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.auth.service.ts new file mode 100644 index 000000000..8401dbb59 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.auth.service.ts @@ -0,0 +1,584 @@ +import { Injectable, Inject, forwardRef, BadRequestException, HttpException, HttpStatus } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { firstValueFrom } from 'rxjs'; +import * as crypto from 'crypto'; + +import { RedisService } from 'src/lib/redis/redis.service'; +import { AuthService } from 'src/auth/auth.service'; +import { AccountService } from 'src/modules/account/account.service'; +import { Account, AccountStatus, AccountType } from 'src/db/schema/account.schema'; +import { AccountToken, TokenPlatform, TokenStatus } from 'src/db/schema/accountToken.schema'; +import { User } from 'src/db/schema/user.schema'; +import { IdService } from 'src/db/id.service'; +import { getCurrentTimestamp } from 'src/util/time.util'; +import axios from 'axios' + +import { TwitterOAuthTokenResponse, TwitterUser } from './dto/twitter.dto'; + +// Twitter API Constants +const TWITTER_API_V2_BASE_URL = 'https://api.twitter.com/2'; +const TOKEN_URL = 'https://api.twitter.com/2/oauth2/token'; +const AUTHORIZE_URL = 'https://twitter.com/i/oauth2/authorize'; + +@Injectable() +export class TwitterAuthService { + private webClientSecret: string; + private webClientId: string; + private webRenderBaseUrl: string; + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + private readonly redisService: RedisService, + private readonly idService: IdService, + @Inject(forwardRef(() => AuthService)) + private readonly authService: AuthService, + private readonly accountService: AccountService, + @InjectModel(User.name) private readonly userModel: Model, + @InjectModel(Account.name) private readonly accountModel: Model, + @InjectModel(AccountToken.name) private readonly accountTokenModel: Model, + ) { + this.initTwitterSecrets(); + } + /** + * 初始化Twitter API密钥 + */ + private initTwitterSecrets() { + // 从配置服务获取Twitter API密钥 + this.webClientId = this.configService.get('TWITTER_CONFIG.WEB_CLIENT_ID'); + this.webClientSecret = this.configService.get('TWITTER_CONFIG.WEB_CLIENT_SECRET'); + this.webRenderBaseUrl = this.configService.get('TWITTER_CONFIG.WEB_RENDER_URL'); + + if (!this.webClientId || !this.webClientSecret || !this.webRenderBaseUrl) { + console.warn('Twitter API配置缺失,请检查环境变量或配置文件'); + } + } + + /** + * 生成PKCE码校验器和挑战码 + */ + private generatePKCE(): { codeVerifier: string; codeChallenge: string } { + // 生成随机码验证器 + const codeVerifier = crypto.randomBytes(32).toString('base64url'); + + // 生成码挑战 + const codeChallenge = crypto + .createHash('sha256') + .update(codeVerifier) + .digest('base64url'); + + return { codeVerifier, codeChallenge }; + } + + /** + * 获取Twitter授权URL + * @param userId 用户ID + * @param mail 用户邮箱 + * @returns 包含授权URL的对象 + */ + async getAuthorizationUrl(userId: string, mail?: string): Promise { + const state = crypto.randomBytes(16).toString('hex'); + const { codeVerifier, codeChallenge } = this.generatePKCE(); + + // 存储状态数据和PKCE验证码 + const stateData = { + originalState: state, + userId: userId, + email: mail, + codeVerifier: codeVerifier + }; + + // 将状态数据保存到Redis,5分钟有效期 + await this.redisService.setKey(`twitter:state:${state}`, JSON.stringify(stateData), 600); + + // 定义请求的权限范围 + const scopes = [ + 'tweet.write', + 'tweet.read', + 'tweet.moderate.write', + 'users.read', + 'space.read', + 'like.read', + 'like.write', + 'list.read', + 'list.write', + 'media.write', + 'offline.access']; // offline.access用于获取刷新令牌 + + // 构建授权URL参数 + const params = new URLSearchParams({ + response_type: 'code', + client_id: this.webClientId, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/twitter/auth/callback`, + scope: scopes.join(' '), + state: state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', // 使用SHA-256算法 + }); + + // 构建完整的授权URL + const authUrl = `${AUTHORIZE_URL}?${params.toString()}`; + console.log('Twitter授权URL:', authUrl); + + return { url: authUrl }; + } + + /** + * 处理Twitter授权回调 + * @param code 授权码 + * @param state 状态码 + * @returns 处理结果 + */ + async handleAuthorizationCallback(code: string, state: string): Promise { + // 从Redis获取存储的状态数据 + const stateDataJson = await this.redisService.get(`twitter:state:${state}`); + if (!stateDataJson) { + throw new BadRequestException('无效或过期的状态码'); + } + // 解析状态数据 + const stateData = JSON.parse(stateDataJson); + const { userId, codeVerifier } = stateData; + + // 验证状态数据 + if (!userId || !codeVerifier) { + throw new BadRequestException('状态数据不完整'); + } + + try { + // 删除Redis中的状态数据 + await this.redisService.del(`twitter:state:${state}`); + + // 交换授权码获取访问令牌 + const tokenResponse = await this.exchangeCodeForTokens(code, codeVerifier); + const { access_token, refresh_token, expires_in, scope } = tokenResponse; + + // 获取Twitter用户资料 + const twitterUser = await this.getTwitterUserProfile(access_token); + + // 获取或存储刷新令牌 + console.log('获取到Twitter访问令牌:', access_token.substring(0, 10) + '...'); + console.log('有效期:', expires_in, '秒'); + + // 更新或创建账户信息 + await this.updateTwitterAccountInfo( + userId, + twitterUser.id, + access_token, + refresh_token, + expires_in + ); + + // 缓存访问令牌 + await this.redisService.setKey( + `twitter:accessToken:${twitterUser.id}`, + { + access_token, + refresh_token, + expires_in + }, + expires_in + ); + + // 查询更新后的账号信息 + const existingAccount = await this.accountModel.findOne({ + type: AccountType.TWITTER, + uid: twitterUser.id + }); + + // 生成并返回结果 + const results = { + data: { + accountInfo: existingAccount, + userInfo: { + userId: userId, + uid: twitterUser.id + } + }, + msg: "success", + code: 0 + }; + + console.log("最终返回", results); + return results; + + } catch (error) { + console.error('处理Twitter授权回调失败:', error); + throw new HttpException( + error.response?.data?.error_description || '授权失败', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + /** + * 交换授权码获取令牌 + * @param code 授权码 + * @param codeVerifier PKCE验证码 + * @returns Twitter OAuth令牌响应 + */ + private async exchangeCodeForTokens(code: string, codeVerifier: string): Promise { + const params = new URLSearchParams({ + grant_type: 'authorization_code', + code: code, + client_id: this.webClientId, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/twitter/auth/callback`, + code_verifier: codeVerifier, + }); + + const base64Credentials = Buffer.from(`${this.webClientId}:${this.webClientSecret}`).toString('base64'); + + try { + const { data } = await firstValueFrom( + this.httpService.post(TOKEN_URL, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${base64Credentials}`, + }, + }), + ); + return data; + } catch (error) { + console.error('交换Twitter授权码失败:', error.response?.data || error.message); + throw new HttpException( + error.response?.data?.error_description || '获取Twitter令牌失败', + error.response?.status || HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * 获取Twitter用户资料 + * @param accessToken 访问令牌 + * @returns Twitter用户资料 + */ + private async getTwitterUserProfile(accessToken: string): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.get<{ data: TwitterUser }>(`${TWITTER_API_V2_BASE_URL}/users/me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + params: { + 'user.fields': 'id,name,username,profile_image_url,description,public_metrics,created_at,verified', + }, + }), + ); + return data.data; + } catch (error) { + console.error('获取Twitter用户资料失败:', error.response?.data || error.message); + throw new HttpException( + error.response?.data?.detail || '获取Twitter用户资料失败', + error.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + /** + * 更新Twitter账户信息 + * 根据Twitter用户ID创建或更新账户 + * @param userId 用户ID + * @param twitterId Twitter用户ID + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + * @param expires_in 令牌有效期(秒) + */ + private async updateTwitterAccountInfo( + userId: string, + twitterId: string, + accessToken: string, + refreshToken: string, + expires_in: number + ): Promise { + try { + // 获取Twitter用户资料 + const twitterUser = await this.getTwitterUserProfile(accessToken); + + // 准备账号信息 + const channelInfo = { + userId: userId, + type: AccountType.TWITTER, + uid: twitterId, + account: twitterUser.username, + nickname: twitterUser.name, + avatar: twitterUser.profile_image_url, + homePage: `https://twitter.com/${twitterUser.username}`, + fansCount: twitterUser.public_metrics?.followers_count || 0, + followCount: twitterUser.public_metrics?.following_count || 0, + workCount: twitterUser.public_metrics?.tweet_count || 0, + likeCount: 0, + readCount: 0, + collectCount: 0, + forwardCount: 0, + commentCount: 0, + updateTime: new Date(), + status: AccountStatus.USABLE, + loginCookie: "1111", // Twitter不使用cookie认证 + token: "111", // 存储访问令牌 + }; + + console.log(channelInfo); + + // 使用AccountService创建或更新账户 + const account = await this.accountService.addOrUpdateAccount(channelInfo); + console.log("成功创建或更新Twitter账号:", account); + + // 检查是否存在账号Token + let accountToken = await this.accountTokenModel.findOne({ + accountId: twitterId, + platform: TokenPlatform.TWITTER + }); + + if (accountToken) { + if (refreshToken && refreshToken.trim() !== '') { + accountToken.refreshToken = refreshToken; + } + // 更新现有Token + // await this.accountTokenModel.findOneAndUpdate( + // { accountId: twitterId, platform: TokenPlatform.TWITTER }, + // { + // refreshToken: refreshToken, + // updateTime: new Date(), + // expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000) + // }, + // ); + accountToken.expiresAt = new Date((getCurrentTimestamp() + expires_in) * 1000); + accountToken.updateTime = new Date(); + await accountToken.save(); + console.log("成功更新Twitter账号Token"); + } else { + // 创建新Token + await this.accountTokenModel.create({ + userId, + accountId: twitterId, + platform: TokenPlatform.TWITTER, + refreshToken: refreshToken, + expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000), + status: TokenStatus.USABLE, + createTime: new Date(), + updateTime: new Date(), + }); + console.log("成功创建Twitter账号Token"); + } + } catch (error) { + console.error('更新Twitter账号信息失败:', error); + // 不抛出异常,避免影响授权流程 + } + } + + /** + * 刷新用户的Twitter访问令牌 + * @param userId 用户ID + * @param accountId 账号ID + * @param refreshToken 刷新令牌 + * @returns 新的访问令牌信息 + */ + async refreshAccessToken(userId: string, accountId: string, refreshToken: string): Promise { + console.log(userId, accountId, refreshToken); + try { + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.webClientId, + // redirect_uri: `${this.webRenderBaseUrl}/api/plat/twitter/auth/callback`, + }); + // 请求体的参数 + // const params = new URLSearchParams({ + // client_id: this.webClientId, // 使用你的 client_id + // client_secret: this.webClientSecret, // 使用你的 client_secret + // refresh_token: refreshToken, // 提供刷新令牌 + // grant_type: 'refresh_token', // 认证类型是刷新令牌 + // }); + + const base64Credentials = Buffer.from(`${this.webClientId}:${this.webClientSecret}`).toString('base64'); + + const { data } = await firstValueFrom( + this.httpService.post(TOKEN_URL, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${base64Credentials}`, + }, + // auth: { + // username: this.webClientId, + // password: this.webClientSecret + // } + }) + ); + // const response = await axios.post(TOKEN_URL, params.toString(), { + // headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + // }); + // console.log("================response================") + // console.log(response); + // const accessTokenInfo = response.data; + // // console.log("================accessTokenInfo================") + // // console.log(accessTokenInfo); + // // 剩余有效秒数 + // const expires = accessTokenInfo.expires_in + // const { data } = await firstValueFrom( + // this.httpService.post(TOKEN_URL, params.toString(), { + // headers: { + // 'Content-Type': 'application/x-www-form-urlencoded' + // } + // }) + // ); + // await this.redisService.setKey( + // `twitter:accessToken:${accountId}`, + // accessTokenInfo, + // expires + // ); + console.log('Twitter API响应:', JSON.stringify(data)); + const { access_token, refresh_token, expires_in } = data; + + // 更新Redis中的令牌 + const TokenInfo = { access_token, refresh_token, expires_in }; + await this.redisService.setKey( + `twitter:accessToken:${accountId}`, + TokenInfo, + expires_in + ); + + console.log('刷新Twitter访问令牌成功'); + const userInfo = await this.userModel.findOne({_id: userId}); + const systemTokenInfo = { + phone: userInfo?.phone ?? '', // 如果 userInfo.phone 为 undefined 或 null,则使用空字符串 + id: userId, + name: userInfo.name, + isManager: false, + googleId: userInfo?.googleAccount?.googleId ?? '' + } + // 生成系统令牌 + const systemToken = await this.authService.generateToken(systemTokenInfo); + + return { url: systemToken }; + } catch (err) { + console.log(err); + console.error('刷新Twitter访问令牌失败:', err.response?.data || err.message); + throw new HttpException( + err.response?.data?.error_description || '刷新令牌失败', + err.response?.status || HttpStatus.BAD_REQUEST + ); + } + } + + /** + * 获取用户的Twitter访问令牌 + * @param accountId 账号ID + * @returns 访问令牌 + */ + async getUserAccessToken(accountId: string): Promise { + console.log("获取访问令牌,accountId:", accountId); + + // 先检查Redis缓存 + const cachedToken = await this.redisService.get(`twitter:accessToken:${accountId}`); + if (cachedToken && cachedToken.access_token) { + console.log("从Redis获取到有效令牌"); + return cachedToken.access_token; + } + + // 如果缓存中没有,尝试刷新 + const accountTokenInfo = await this.accountTokenModel.findOne({accountId: accountId}); + if (!accountTokenInfo || !accountTokenInfo.refreshToken) { + throw new BadRequestException('无效的账号或刷新令牌丢失'); + } + + // 刷新并获取新令牌 + const refreshResult = await this.refreshAccessToken( + accountTokenInfo.userId, + accountTokenInfo.accountId, + accountTokenInfo.refreshToken + ); + + // 刷新后再次从Redis获取 + const newToken = await this.redisService.get(`twitter:accessToken:${accountId}`); + if (!newToken || !newToken.access_token) { + throw new BadRequestException('刷新令牌后未能获取访问令牌'); + } + + return newToken.access_token; + } + + /** + * 检查用户是否已授权Twitter + * @param accountId 账号ID + * @returns 是否已授权 + */ + async isAuthorized(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + return !!accessToken; + } catch (error) { + return false; + } + } + + /** + * 撤销Twitter授权 + * @param accountId 账号ID + * @returns 撤销结果 + */ + async revokeAuthorization(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + if (!accessToken) { + return true; // 已经没有授权了 + } + + // 撤销Twitter令牌 + const params = new URLSearchParams({ + token: accessToken, + client_id: this.webClientId, + token_type_hint: 'access_token' + }); + + const base64Credentials = Buffer.from(`${this.webClientId}:${this.webClientSecret}`).toString('base64'); + + await firstValueFrom( + this.httpService.post(`${TWITTER_API_V2_BASE_URL}/oauth2/revoke`, params.toString(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${base64Credentials}`, + }, + }), + ); + + // 删除缓存的令牌 + await this.redisService.del(`twitter:accessToken:${accountId}`); + + // 更新用户信息,移除授权信息 + await this.accountTokenModel.updateOne( + { accountId: accountId }, + { $unset: { 'refreshToken': 1, 'expiresAt': 1 } } + ); + + return true; + } catch (error) { + console.error('撤销Twitter授权失败:', error); + return false; + } + } + + /** + * 获取初始化后的Twitter API客户端 + * @param accountId 账号ID + * @returns 初始化后的客户端 + */ + async getTwitterClient(accountId: string): Promise { + const accessToken = await this.getUserAccessToken(accountId); + if (!accessToken) { + throw new Error('No access token available, user needs to authorize'); + } + + // 返回一个简单的API客户端,可以根据需要扩展 + return { + headers: { + Authorization: `Bearer ${accessToken}` + }, + baseUrl: TWITTER_API_V2_BASE_URL, + async get(endpoint: string, params = {}) { + // 这里可以实现实际的API调用逻辑 + // 或者使用第三方Twitter客户端库 + } + }; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.controller.ts new file mode 100644 index 000000000..0ccd657f6 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.controller.ts @@ -0,0 +1,385 @@ +import { Controller, Get, Post, Body, Query, Res, BadRequestException, Delete, Param, HttpCode, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiQuery, ApiBody, ApiParam, ApiConsumes } from '@nestjs/swagger'; +import { Response } from 'express'; +import { GetToken, Public } from 'src/auth/auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; + +import { TwitterAuthService } from './twitter.auth.service'; +import { TwitterService } from './twitter.service'; + +@ApiTags('plat/twitter - twitter平台') +@Controller('plat/twitter') +export class TwitterController { + constructor( + private readonly twitterAuthService: TwitterAuthService, + private readonly twitterService: TwitterService, + ) {} + + /** + * 获取Twitter授权URL + */ + @Get('auth/url') + @ApiOperation({ summary: '获取Twitter授权URL' }) + @ApiQuery({ name: 'mail', required: false, description: '用户邮箱' }) + async getAuthUrl( + @GetToken() systemToken: TokenInfo, + @Query('mail') mail: string, + ) { + if (!systemToken.id || !mail) { + throw new BadRequestException('token和mail是必须的'); + } + return this.twitterAuthService.getAuthorizationUrl(systemToken.id, mail); + } + + /** + * 处理Twitter OAuth回调 + */ + @Get('auth/callback') + @ApiOperation({ summary: 'Twitter授权回调' }) + // @ApiQuery({ name: 'code', required: true, description: '授权码' }) + // @ApiQuery({ name: 'state', required: true, description: '状态值' }) + @Public() + async handleOAuthCallback( + // @GetToken() systemToken: TokenInfo, + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response, + ) { + if (!code || !state) { + throw new BadRequestException('缺少必要的参数'); + } + console.log(code, state); + try { + + // 处理授权回调 + const results = await this.twitterAuthService.handleAuthorizationCallback(code, state); + + // 获取重定向URL,如果存在的话 + // const redirectUrl = process.env.TWITTER_AUTH_SUCCESS_REDIRECT || 'https://your-frontend-app/auth/success'; + + // // 重定向到前端应用,带上必要的参数 + // return res.redirect(`${redirectUrl}?success=true`); + const render_msg = { + message: "授权成功! 这里是添加账号成功后的前端页面," , + datas: results + }; + + return res.render('google/index', render_msg); + + } catch (error) { + console.error('Twitter授权回调处理失败:', error); + + // 获取失败重定向URL + const failureRedirectUrl = process.env.TWITTER_AUTH_FAILURE_REDIRECT || 'https://your-frontend-app/auth/failure'; + + // 重定向到前端失败页面,带上错误信息 + // return res.redirect(`${failureRedirectUrl}?error=${encodeURIComponent(error.message || 'Unknown error')}`); + return false + } + } + + /** + * 检查用户是否已授权Twitter + */ + @Get('auth/check') + @ApiOperation({ summary: '检查用户Twitter授权状态' }) + @ApiQuery({ name: 'accountId', required: true, description: 'Twitter账号ID' }) + async checkAuthStatus( + @GetToken() systemToken: TokenInfo, + @Query('accountId') accountId: string + ) { + if (!accountId) { + throw new BadRequestException('accountId是必须的'); + } + + return await this.twitterAuthService.isAuthorized(accountId); + } + + /** + * 撤销Twitter授权 + */ + @Post('auth/revoke') + @ApiOperation({ summary: '撤销Twitter授权' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'Twitter账号ID' } + } + } + }) + async revokeAuth( + @GetToken() systemToken: TokenInfo, + @Body('accountId') accountId: string + ) { + if (!accountId) { + throw new BadRequestException('accountId是必须的'); + } + + return await this.twitterAuthService.revokeAuthorization(accountId); + } + + /** + * 刷新Twitter访问令牌 + */ + @Post('auth/refresh') + @ApiOperation({ summary: '刷新Twitter访问令牌' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + userId: { type: 'string', description: '用户ID' }, + accountId: { type: 'string', description: 'Twitter账号ID' }, + refreshToken: { type: 'string', description: '刷新令牌' } + } + } + }) + async refreshToken( + @GetToken() systemToken: TokenInfo, + @Body('userId') userId: string, + @Body('accountId') accountId: string, + @Body('refreshToken') refreshToken: string + ) { + if (!userId || !accountId || !refreshToken) { + throw new BadRequestException('缺少必要的参数'); + } + + return this.twitterAuthService.refreshAccessToken(userId, accountId, refreshToken); + } + + /** + * 获取用户的Twitter时间线 + */ + @Get('timeline') + @ApiOperation({ summary: '获取用户的Twitter时间线' }) + // @ApiQuery({ name: 'userId', required: true, description: '用户ID' }) + @ApiQuery({ name: 'accountId', required: true, description: '账号ID' }) + @ApiQuery({ name: 'maxResults', required: false, description: '最大结果数', type: 'number' }) + async getUserTimeline( + @GetToken() systemToken: TokenInfo, + // @Query('userId') userId: string, + @Query('accountId') accountId: string, + @Query('maxResults') maxResults?: number, + ) { + + const userId = systemToken.id; + if (!userId || !accountId) { + throw new BadRequestException('userId和accountId是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + + return this.twitterService.getUserTimeline(accessToken, userId, accountId, maxResults); + } + + /** + * 发布新推文 + */ + @Post('tweets/create') + @ApiOperation({ summary: '发布新推文' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + // userId: { type: 'string', description: '用户ID' }, + accountId: { type: 'string', description: 'Twitter账号ID' }, + text: { type: 'string', description: '推文内容' }, + mediaIds: { type: 'array', items: { type: 'string' }, description: '媒体ID列表(可选)' } + }, + required: ['userId', 'accountId', 'text'] + } + }) + @HttpCode(201) + async createTweet( + @GetToken() systemToken: TokenInfo, + // @Body('userId') userId: string, + @Body('accountId') accountId: string, + @Body('text') text: string, + @Body('mediaIds') mediaIds?: string[], + ) { + const userId = systemToken.id; + if (!userId || !accountId || !text) { + throw new BadRequestException('userId, accountId和text是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.createTweet(accessToken, userId, accountId, text, mediaIds); + } + + /** + * 上传媒体文件 + */ + @Post('media/upload') + @ApiOperation({ summary: '上传媒体文件' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + properties: { + userId: { type: 'string' }, + accountId: { type: 'string' }, + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @UseInterceptors(FileInterceptor('file')) + async uploadMedia( + @GetToken() systemToken: TokenInfo, + // @Body('userId') userId: string, + @Body('accountId') accountId: string, + @UploadedFile() file: Express.Multer.File, + ) { + const userId = systemToken.id; + if (!userId || !accountId || !file) { + throw new BadRequestException('userId, accountId和file是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.uploadMedia(accessToken, userId, accountId, file.buffer, file.mimetype); + } + + /** + * 获取推文详情 + */ + @Get('tweets/detail') + @ApiOperation({ summary: '获取推文详情' }) + @ApiQuery({ name: 'tweetId', type: 'string', description: '推文ID' }) + // @ApiQuery({ name: 'userId', required: true, description: '用户ID' }) + @ApiQuery({ name: 'accountId', required: true, description: 'Twitter账号ID' }) + async getTweetDetail( + @GetToken() systemToken: TokenInfo, + @Query('tweetId') tweetId: string, + // @Query('userId') userId: string, + @Query('accountId') accountId: string, + ) { + const userId = systemToken.id; + if (!tweetId || !userId || !accountId) { + throw new BadRequestException('tweetId, userId和accountId是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.getTweetDetail(accessToken, userId, accountId, tweetId); + } + + /** + * 获取推文统计数据 + */ + @Get('tweets/metrics') + @ApiOperation({ summary: '获取推文统计数据' }) + @ApiQuery({ name: 'tweetId', type: 'string', description: '推文ID' }) + // @ApiQuery({ name: 'userId', required: true, description: '用户ID' }) + @ApiQuery({ name: 'accountId', required: true, description: 'Twitter账号ID' }) + async getTweetMetrics( + @GetToken() systemToken: TokenInfo, + @Query('tweetId') tweetId: string, + // @Query('userId') userId: string, + @Query('accountId') accountId: string, + ) { + const userId = systemToken.id; + if (!tweetId || !userId || !accountId) { + throw new BadRequestException('tweetId, userId和accountId是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.getTweetMetrics(accessToken, userId, accountId, tweetId); + } + + /** + * 删除推文 + */ + @Post('tweets/delete') + @ApiOperation({ summary: '删除推文' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID' }, + tweetId: { type: 'string', description: '推文ID' } + }, + required: ['accountId', 'tweetId'] + } + }) + + async deleteTweet( + @GetToken() systemToken: TokenInfo, + @Body('tweetId') tweetId: string, + @Body('accountId') accountId: string, + ) { + const userId = systemToken.id; + if (!tweetId || !userId || !accountId) { + throw new BadRequestException('tweetId, userId和accountId是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.deleteTweet(accessToken, userId, accountId, tweetId); + } + + /** + * 搜索推文 + */ + @Get('tweets/search') + @ApiOperation({ summary: '搜索推文' }) + @ApiQuery({ name: 'accountId', required: true, description: 'Twitter账号ID' }) + @ApiQuery({ name: 'query', required: true, description: '搜索关键词' }) + @ApiQuery({ name: 'maxResults', required: false, description: '最大结果数', type: 'number' }) + async searchTweets( + @GetToken() systemToken: TokenInfo, + @Query('accountId') accountId: string, + @Query('query') query: string, + @Query('maxResults') maxResults?: number, + ) { + const userId = systemToken.id; + if (!userId || !accountId || !query) { + throw new BadRequestException('userId, accountId和query是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + return this.twitterService.searchTweets(accessToken, userId, accountId, query, maxResults); + } + + /** + * 对推文点赞、取消点赞 + */ + @Post('tweets/rate') + @ApiOperation({ summary: '对推文点赞、取消点赞' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + accountId: { type: 'string', description: '账号ID' }, + tweetId: { type: 'string', description: '推文ID' }, + rating: { type: 'string', description: '点赞 like、取消点赞 unlike' } + }, + required: ['accountId', 'tweetId', 'rating'] + } + }) + async rateTweet( + @GetToken() systemToken: TokenInfo, + @Body('tweetId') tweetId: string, + @Body('accountId') accountId: string, + @Body('rating') rating: string, + ) { + const userId = systemToken.id; + if (!tweetId || !userId || !accountId || ! rating) { + throw new BadRequestException('tweetId, rating和accountId是必须的'); + } + + const accessToken = await this.twitterAuthService.getUserAccessToken(accountId); + // 在方法开始处验证 + if (!["like", "unlike"].includes(rating)) { + throw new BadRequestException('参数错误: rating必须为like或unlike'); + } + + // 后续处理 + if (rating === "like") { + return this.twitterService.likeTweet(accessToken, userId, accountId, tweetId); + } else { + return this.twitterService.unlikeTweet(accessToken, userId, accountId, tweetId); + } + } + +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.module.ts new file mode 100644 index 000000000..53c970609 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.module.ts @@ -0,0 +1,45 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { TwitterController } from './twitter.controller'; +import { TwitterService } from './twitter.service'; +import { TwitterAuthService } from './twitter.auth.service'; + +import { Account, AccountSchema } from 'src/db/schema/account.schema'; +import { AccountToken, AccountTokenSchema } from 'src/db/schema/accountToken.schema'; +import { PubRecord, PubRecordSchema } from 'src/db/schema/pubRecord.schema'; +import { User, UserSchema } from 'src/db/schema/user.schema'; + +import { AccountModule } from 'src/modules/account/account.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { RedisModule } from 'src/lib/redis/redis.module'; +import { UserModule } from 'src/user/user.module'; +import googleConfig from 'config/google.config'; + +// You'll need to create a twitter.config.ts similar to your google.config.ts +// import twitterConfig from 'config/twitter.config'; + +@Module({ + imports: [ + HttpModule, // For making HTTP requests + ConfigModule.forRoot({ + load: [googleConfig], + }), + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: Account.name, schema: AccountSchema }, + { name: AccountToken.name, schema: AccountTokenSchema }, + { name: PubRecord.name, schema: PubRecordSchema }, + ]), + forwardRef(() => AuthModule), + forwardRef(() => AccountModule), + forwardRef(() => UserModule), + RedisModule, + ], + controllers: [TwitterController], + providers: [TwitterService, TwitterAuthService, ConfigService], + exports: [TwitterService, TwitterAuthService], +}) +export class TwitterModule {} \ No newline at end of file diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.service.ts new file mode 100644 index 000000000..6e325b89e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/twitter/twitter.service.ts @@ -0,0 +1,374 @@ +import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { HttpService } from '@nestjs/axios'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { lastValueFrom } from 'rxjs'; +import { PubType, PubStatus, PubRecord } from 'src/db/schema/pubRecord.schema' + +import { TwitterAuthService } from './twitter.auth.service'; + +// Twitter API V2基础URL +const TWITTER_API_V2_URL = 'https://api.twitter.com/2'; + +@Injectable() +export class TwitterService { + private readonly logger = new Logger(TwitterService.name); + + constructor( + private readonly configService: ConfigService, + private readonly httpService: HttpService, + private readonly twitterAuthService: TwitterAuthService, + @InjectModel(PubRecord.name) + private readonly PubRecordModel: Model, + + ) {} + + + /** + * 获取用户的Twitter时间线 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param maxResults 最大结果数 + * @returns Twitter时间线数据 + */ + async getUserTimeline(accessToken: string, userId: string, accountId: string, maxResults: number = 10) { + try { + // 确保maxResults是一个有效的整数 + const validMaxResults = Number(maxResults); + + // 调用Twitter API获取时间线 + const url = `${TWITTER_API_V2_URL}/users/${accountId}/tweets`; + // console.log("timeline url:", url); + const params = { + 'max_results': isNaN(validMaxResults) ? 10 : validMaxResults, // 如果是NaN则使用默认值10 + 'tweet.fields': 'created_at,public_metrics,text,source', + 'expansions': 'attachments.media_keys', + 'media.fields': 'url,preview_image_url,type' + }; + console.log("请求参数:", params); + // console.log("accessToken:", accessToken); + const response = await lastValueFrom( + this.httpService.get(url, { + params, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ); + console.log("==============response============"); + console.log(response); + return response.data + } catch (error) { + console.error('完整错误对象:', JSON.stringify(error.response?.data || error.message)); + + this.logger.error(`获取Twitter时间线失败: ${error.message}`, error.stack); + throw new BadRequestException(`获取Twitter时间线失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 发布新推文 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param text 推文内容 + * @param mediaIds 媒体ID数组 + * @returns 发布结果 + */ + async createTweet(accessToken: string, userId: string, accountId: string, text: string, mediaIds?: string[]) { + // 获取当前最大的 id + const maxRecord = await this.PubRecordModel.findOne().sort({ id: -1 }); + const newId = maxRecord ? maxRecord.id + 1 : 1; + + try { + + + const url = `${TWITTER_API_V2_URL}/tweets`; + const payload: any = { text }; + + // 如果有媒体附件,添加媒体信息 + if (mediaIds && mediaIds.length > 0) { + payload.media = { + media_ids: mediaIds + }; + } + + // 创建发布记录 + let newData: any = { + userId: userId, + type: PubType.ARTICLE, + title: text, + desc: text, + accountId: accountId, + status:PubStatus.UNPUBLISH, + // timingTime: publishAt, + publishTime: new Date() + } + + // if (publishAt) { + // requestBody.status.publishAt = publishAt; // 如果提供了 publishAt 则使用 publishAt + // } + + // console.log(requestBody); + + await this.PubRecordModel.create({ + ...newData, + id: newId, + }); + + const response = await lastValueFrom( + this.httpService.post(url, payload, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + this.logger.log(`成功发布推文: userId=${userId}, accountId=${accountId}`); + + // 更新发布记录 + await this.PubRecordModel.updateOne({ id:newId }, { + status: PubStatus.RELEASED, + publishTime: new Date() + }); + + return response.data.data + } catch (error) { + await this.PubRecordModel.updateOne({ id:newId }, { status: PubStatus.FAIL }); + this.logger.error(`发布推文失败: ${error.message}`, error.stack); + throw new BadRequestException(`发布推文失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 上传媒体文件 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param mediaFile 媒体文件Buffer + * @param mimeType 媒体类型 + * @returns 媒体上传结果 + */ + async uploadMedia(accessToken, userId: string, accountId: string, mediaFile: Buffer, mimeType: string) { + try { + + + // Twitter有单独的媒体上传API + const url = 'https://upload.twitter.com/1.1/media/upload.json'; + + // 创建表单数据 + const formData = new FormData(); + formData.append('media', new Blob([mediaFile], { type: mimeType })); + + const response = await lastValueFrom( + this.httpService.post(url, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + this.logger.log(`媒体上传成功: userId=${userId}, accountId=${accountId}`); + + return response.data + } catch (error) { + this.logger.error(`上传媒体失败: ${error.message}`, error.stack); + throw new BadRequestException(`上传媒体失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 删除推文 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param tweetId 推文ID + * @returns 删除结果 + */ + async deleteTweet(accessToken, userId: string, accountId: string, tweetId: string) { + try { + + const url = `${TWITTER_API_V2_URL}/tweets/${tweetId}`; + + await lastValueFrom( + this.httpService.delete(url, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + this.logger.log(`成功删除推文: tweetId=${tweetId}, accountId=${accountId}`); + + return true + } catch (error) { + this.logger.error(`删除推文失败: ${error.message}`, error.stack); + throw new BadRequestException(`删除推文失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 获取推文详情 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param tweetId 推文ID + * @returns 推文详情 + */ + async getTweetDetail(accessToken, userId: string, accountId: string, tweetId: string) { + try { + + const url = `${TWITTER_API_V2_URL}/tweets/${tweetId}`; + const params = { + 'tweet.fields': 'created_at,public_metrics,text,source', + 'expansions': 'attachments.media_keys,author_id', + 'media.fields': 'url,preview_image_url,type' + }; + + const response = await lastValueFrom( + this.httpService.get(url, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return response.data.data + } catch (error) { + this.logger.error(`获取推文详情失败: ${error.message}`, error.stack); + throw new BadRequestException(`获取推文详情失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 获取推文的统计数据 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param tweetId 推文ID + * @returns 推文统计数据 + */ + async getTweetMetrics(accessToken, userId: string, accountId: string, tweetId: string) { + try { + + const url = `${TWITTER_API_V2_URL}/tweets/${tweetId}`; + const params = { + 'tweet.fields': 'public_metrics,non_public_metrics,organic_metrics', // 注意:某些指标需要高级API访问权限 + }; + + const response = await lastValueFrom( + this.httpService.get(url, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return response.data; + } catch (error) { + this.logger.error(`获取推文统计数据失败: ${error.message}`, error.stack); + throw new BadRequestException(`获取推文统计数据失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 查找用户的推文 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param query 查询内容 + * @param maxResults 最大结果数 + * @returns 搜索结果 + */ + async searchTweets(accessToken, userId: string, accountId: string, query: string, maxResults: number = 10) { + try { + // 确保maxResults是一个有效的整数 + const validMaxResults = Number(maxResults); + + const url = `${TWITTER_API_V2_URL}/tweets/search/recent`; + const params = { + 'query': `from:${accountId} ${query}`, + 'max_results': isNaN(validMaxResults) ? 10 : validMaxResults, // 如果是NaN则使用默认值10 + 'tweet.fields': 'created_at,public_metrics,text', + 'expansions': 'attachments.media_keys', + 'media.fields': 'url,preview_image_url,type' + }; + + const response = await lastValueFrom( + this.httpService.get(url, { + params, + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + return response.data; + } catch (error) { + this.logger.error(`搜索推文失败: ${error.message}`, error.stack); + throw new BadRequestException(`搜索推文失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 对推文点赞 + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param tweetId 推文ID + * @returns 点赞结果 + */ + async likeTweet(accessToken: string, userId: string, accountId: string, tweetId: string) { + try { + // Twitter API V2 点赞端点 + const url = `${TWITTER_API_V2_URL}/users/${accountId}/likes`; + const data = { + tweet_id: tweetId + }; + + const response = await lastValueFrom( + this.httpService.post(url, data, { + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + } + }) + ); + + this.logger.log(`成功点赞推文: userId=${userId}, accountId=${accountId}, tweetId=${tweetId}`); + return response.data; + } catch (error) { + this.logger.error(`点赞推文失败: ${error.message}`, error.stack); + throw new BadRequestException(`点赞推文失败: ${error.response?.data?.error || error.message}`); + } + } + + /** + * 取消对推文的点赞 + * @param accessToken 访问令牌 + * @param userId 用户ID + * @param accountId Twitter账号ID + * @param tweetId 推文ID + * @returns 取消点赞结果 + */ + async unlikeTweet(accessToken: string, userId: string, accountId: string, tweetId: string) { + try { + // Twitter API V2 取消点赞端点 + const url = `${TWITTER_API_V2_URL}/users/${accountId}/likes/${tweetId}`; + + const response = await lastValueFrom( + this.httpService.delete(url, { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }) + ); + + this.logger.log(`成功取消点赞推文: userId=${userId}, accountId=${accountId}, tweetId=${tweetId}`); + return response.data; + } catch (error) { + this.logger.error(`取消点赞推文失败: ${error.message}`, error.stack); + throw new BadRequestException(`取消点赞推文失败: ${error.response?.data?.error || error.message}`); + } + } + +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/comment.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/comment.ts new file mode 100644 index 000000000..2fd4f47f9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/comment.ts @@ -0,0 +1,13 @@ +export enum VideoUTypes { + Little = 0, + Big = 1, +} + +export interface AccessToken { + access_token: string; // 'd30bedaa4d8eb3128cf35ddc1030e27d'; + expires_in: number; // 1630220614; + refresh_token: string; // 'WxFDKwqScZIQDm4iWmKDvetyFugM6HkX'; + scopes: string[]; // ['USER_INFO', 'ATC_DATA', 'ATC_BASE']; + token_type: string; + id_token: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/dto/youtube.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/dto/youtube.dto.ts new file mode 100644 index 000000000..93469fc3f --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/dto/youtube.dto.ts @@ -0,0 +1,132 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:16:37 + * @LastEditors: nevin + * @Description: + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { VideoUTypes } from '../comment'; + +export class AccessBackDto { + @Expose() + code: string; + + @Expose() + state: string; +} + +export class VideoInitDto { + @ApiProperty({ title: '文件名称(带格式)', required: true }) + @IsString({ message: '文件名称' }) + @Expose() + readonly fileName: string; + + @ApiProperty({ enum: VideoUTypes }) + @IsNotEmpty({ message: '文件类型不能为空' }) + @IsEnum(VideoUTypes) + @Type(() => Number) + @Expose() + readonly utype: VideoUTypes; +} + +export class ArchiveAddByUtokenQueryDto { + @ApiProperty({ title: '用户token', required: true }) + @IsString({ message: '用户token' }) + @Expose() + readonly accessToken: string; + + @ApiProperty({ title: '上传token', required: true }) + @IsString({ message: '上传token' }) + @Expose() + readonly uploadToken: string; +} + +export class ArchiveAddByUtokenBodyDto { + @ApiProperty({ title: '标题', required: true }) + @IsString({ message: '标题' }) + @Expose() + readonly title: string; + + @ApiProperty({ title: '封面url', required: false }) + @IsString({ message: '封面url' }) + @IsOptional() + @Expose() + readonly cover?: string; + + @ApiProperty({ title: '分区ID,由获取分区信息接口得到', required: true }) + @IsNumber({ allowNaN: false }, { message: '分区ID,由获取分区信息接口得到' }) + @Expose() + readonly tid: number; + + @ApiProperty({ + title: '是否允许转载 0-允许,1-不允许。默认0', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '是否允许转载 0-允许,1-不允许。默认0' }, + ) + @IsOptional() + @Expose() + readonly noReprint?: 0 | 1; + + @ApiProperty({ title: '描述', required: false }) + @IsString({ message: '描述' }) + @IsOptional() + @Expose() + readonly desc?: string; + + @ApiProperty({ title: '标签', required: false }) + @IsArray({ message: '标签必须是字符串数组' }) + @IsString({ each: true, message: '描述' }) + @Expose() + readonly tag: string[]; + + @ApiProperty({ + title: '1-原创,2-转载(转载时source必填)', + required: true, + }) + @IsNumber( + { allowNaN: false }, + { message: '1-原创,2-转载(转载时source必填)' }, + ) + @Expose() + readonly copyright: 1 | 2; + + @ApiProperty({ + title: '如果copyright为转载,则此字段表示转载来源', + required: false, + }) + @IsString({ message: '如果copyright为转载,则此字段表示转载来源' }) + @IsOptional() + @Expose() + readonly source?: string; +} + +// 定义类型 +export interface YoutubePlaylistSnippet { + title?: string; + description?: string; + defaultLanguage?: string; +} + +export interface YoutubePlaylistStatus { + privacyStatus?: string; + podcastStatus?: string; +} + +export interface YouTubeAuthTokens { + accessToken: string; + refreshToken?: string; + expiresAt?: number; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.auth.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.auth.service.ts new file mode 100644 index 000000000..7819f793e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.auth.service.ts @@ -0,0 +1,644 @@ +/* + * @Author: nevin + * @Date: 2025-05-27 14:48:12 + * @LastEditTime: 2025-05-27 14:48:12 + * @LastEditors: nevin + * @Description: YouTube授权服务 + */ +import { Injectable, Inject, forwardRef } from '@nestjs/common'; +import { GoogleService } from '../google/google.service'; +import { google } from 'googleapis'; +import { AccessToken } from './comment'; +import { getRandomString } from 'src/util'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { User } from 'src/db/schema/user.schema'; +import { Account, AccountType, AccountStatus } from 'src/db/schema/account.schema'; +import { AccountToken, TokenPlatform, TokenStatus } from 'src/db/schema/accountToken.schema'; +import { RedisService } from 'src/lib/redis/redis.service'; +import { AuthService } from 'src/auth/auth.service'; +import { getCurrentTimestamp } from 'src/util/time.util'; +import { YouTubeAuthTokens } from './dto/youtube.dto' +import axios from 'axios'; +import { ConfigService } from '@nestjs/config'; +import { IdService } from 'src/db/id.service'; +import { AccountService } from 'src/modules/account/account.service'; + + +@Injectable() +export class YouTubeAuthService { + + private webClientSecret: string; + private webClientId: string; + private webRenderBaseUrl: string; + + constructor( + private configService: ConfigService, + private readonly idService: IdService, + + @Inject(forwardRef(() => GoogleService)) + private readonly googleService: GoogleService, + private readonly redisService: RedisService, + @InjectModel(User.name) + private userModel: Model, + @InjectModel(Account.name) + private accountModel: Model, + private readonly AuthService: AuthService, + @InjectModel(AccountToken.name) + private AccountTokenModel: Model, + private readonly accountService: AccountService, + ) { + this.initGoogleSecrets(); + } + + private async initGoogleSecrets() { + this.webClientSecret = this.configService.get("GOOGLE_CONFIG.WEB_CLIENT_SECRET"); + this.webClientId = this.configService.get("GOOGLE_CONFIG.WEB_CLIENT_ID"); + this.webRenderBaseUrl = this.configService.get("GOOGLE_CONFIG.WEB_RENDER_URL"); + } + + private async getId() { + return this.idService.createId('accountId', 100000000, 1); + } + + /** + * 初始化YouTube API客户端 + * @param accessToken 访问令牌 + * @returns YouTube API客户端 + */ + initializeYouTubeClient(accessToken: string): any { + const auth = new google.auth.OAuth2(); + auth.setCredentials({ access_token: accessToken }); + return google.youtube({ version: 'v3', auth }); + } + + /** + * 获取YouTube授权URL + * @param mail 用户邮箱 + * @returns 授权URL + */ + async getAuthorizationUrl(mail: string, userId: string): Promise { + try { + const state = getRandomString(8); + this.redisService.setKey(`youtube:state:${userId}:${state}`, { mail }, 60 * 10); + + // 指定YouTube特定的scope + const youtubeScopes = [ + "https://www.googleapis.com/auth/youtube.force-ssl", + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.upload", + "https://www.googleapis.com/auth/userinfo.profile" + ]; + + const stateData = { + originalState: state, // 保留原始state值 + userId: userId, // 添加token + email: mail + }; + + // 将状态数据转换为JSON字符串并编码 + const encodedState = encodeURIComponent(JSON.stringify(stateData)); + + const params = new URLSearchParams({ + scope: youtubeScopes.join(" "), + access_type: "offline", + include_granted_scopes: "true", + response_type: "code", + state: encodedState, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/youtube/auth/callback`, + client_id: this.webClientId, + prompt: "consent", // 强制要求用户确认授权,以便我们能够获取refresh_token + // login_hint: userId, + }); + + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.search = params.toString(); + + return {url: authUrl.toString()}; + } catch (error) { + console.error('Error generating auth URL:', error); + throw new Error('无法生成授权URL'); + } + } + + /** + * 获取用户的YouTube访问令牌 + * @param accountId 账号ID + * @returns 访问令牌 + */ + async getUserAccessToken(accountId: string): Promise { + console.log("accountId:--", accountId); + const accountTokenInfo = await this.AccountTokenModel.findOne({accountId: accountId}); + + // if (!res) return ''; + + // // 剩余时间 + // const overTime = res.expires_in; + + // if (overTime < 60 * 60 && overTime > 0) { + // // 刷新token + // this.refreshAccessToken(userId, res.refresh_token); +// } + // const accountTokenInfo = await this.AccountTokenModel.findOne({accountId: accountId}); + await this.refreshAccessToken(accountTokenInfo.userId, accountTokenInfo.accountId, accountTokenInfo.refreshToken); + + const res: AccessToken = await this.redisService.get( + `youtube:accessToken:${accountId}`, + ); + return res.access_token; + } + + /** + * 刷新用户的YouTube访问令牌 + * @param accountId 账号ID + * @returns 新的系统令牌 + */ + async refreshAccessToken(userId: string, accountId: string, refreshToken: string): Promise { + try { + const userInfo = await this.userModel.findOne({_id: userId}); + // if(!refreshToken) { + + // console.log("=============userInfo===================="); + // console.log(userInfo) + // refreshToken = userInfo?.googleAccount?.refreshToken ?? '' + // } + + const tokenUrl = 'https://oauth2.googleapis.com/token'; + + // 请求体的参数 + const params = new URLSearchParams({ + client_id: this.webClientId, // 使用你的 client_id + client_secret: this.webClientSecret, // 使用你的 client_secret + refresh_token: refreshToken, // 提供刷新令牌 + grant_type: 'refresh_token', // 认证类型是刷新令牌 + }); + + // 发送 POST 请求到 Google token endpoint 来刷新 access token + const response = await axios.post(tokenUrl, params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + console.log("================response================") + console.log(response); + const accessTokenInfo = response.data; + // console.log("================accessTokenInfo================") + // console.log(accessTokenInfo); + // 剩余有效秒数 + const expires = accessTokenInfo.expires_in + // accessTokenInfo.expires_in - getCurrentTimestamp() - 60 * 60; + this.redisService.setKey( + `youtube:accessToken:${accountId}`, + accessTokenInfo, + expires, + ); + + const TokenInfo = { + phone: userInfo?.phone ?? '', // 如果 userInfo.phone 为 undefined 或 null,则使用空字符串 + id: userId, + name: userInfo.name, + isManager: false, + googleId: userInfo?.googleAccount?.googleId ?? '' + } + console.log("发送获取systemToken的info---", TokenInfo); + const systemToken = await this.AuthService.generateToken(TokenInfo) + + const returnRes = {url: systemToken}; + return returnRes; + // 返回新的 access token 和其他信息 + // return response.data; // 包含新的 access_token、expires_in、token_type 等信息 + } catch (err) { + console.log('Error while refreshing access token', err); + throw new Error('Failed to refresh access token'); + } + } + + /** + * 验证并保存授权码 + * @param code 授权码 + * @param state 状态码 + * @returns 系统令牌 + */ + async handleAuthorizationCode(code: string, state: string, userId: string) { + try { + // 获取state关联的邮箱信息 + const stateInfo = await this.redisService.get(`youtube:state:${userId}:${state}`); + if (!stateInfo || !stateInfo.mail) { + throw new Error('无效的状态码'); + } + + // 使用授权码获取访问令牌和刷新令牌 + const params = new URLSearchParams({ + code: code, + redirect_uri: `${this.webRenderBaseUrl}/api/plat/youtube/auth/callback`, + client_id: this.webClientId, + grant_type: "authorization_code", + client_secret: this.webClientSecret, + }); + + const response = await axios.post('https://oauth2.googleapis.com/token', params.toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + const { access_token, refresh_token, expires_in, id_token } = response.data; + + // 验证ID令牌以获取用户信息 + const oauth2Client = new google.auth.OAuth2(); + oauth2Client.setCredentials({ access_token }); + + const ticket = await oauth2Client.verifyIdToken({ + idToken: id_token, + audience: this.webClientId + }); + + const payload = ticket.getPayload(); + const googleId = payload.sub; + const email = payload.email; + + // 获取YouTube频道信息,用于更新账号数据库 + await this.updateYouTubeAccountInfo(userId, email, googleId, access_token, refresh_token, expires_in); + + // 缓存令牌 + await this.redisService.setKey( + `youtube:accessToken:${googleId}`, + { + access_token, + refresh_token, + expiresAt: getCurrentTimestamp() + expires_in + }, + expires_in + ); + + // 查询AccountToken数据库里是否存在令牌,如果存在,且上面获得refresh_token 存在,且不为空或null,则更新 + // 如果不存在,则创建 + let accountToken = await this.AccountTokenModel.findOne({ + platform: AccountType.YOUTUBE, + accountId: googleId + }); + + if (accountToken) { + // 更新现有令牌 + if (refresh_token && refresh_token.trim() !== '') { + accountToken.refreshToken = refresh_token; + } + + accountToken.expiresAt = new Date((getCurrentTimestamp() + expires_in) * 1000); + accountToken.updateTime = new Date(); + await accountToken.save(); + } else { + // 创建新的令牌记录 + accountToken = await this.AccountTokenModel.create({ + userId, + platform: AccountType.YOUTUBE, + accountId: googleId, + refreshToken: refresh_token, + status: TokenStatus.USABLE, + createTime: new Date(), + updateTime: new Date(), + expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000) + }); + } + + // // 返回系统令牌 + // return this.AuthService.generateToken(TokenInfo); + const existingAccount = await this.accountModel.findOne({ + type: AccountType.YOUTUBE, + googleId: googleId + }); + + const results = { + data: + { + accountInfo: existingAccount, + userInfo: { + "userId": userId, + "uid": googleId + } + }, + msg:"success", code: 0 + + }; + console.log("最终返回", results); + + return results; + + } catch (error) { + console.error('处理授权码失败:', error); + throw new Error('授权失败'); + } + } + + /** + * 获取YouTube频道信息并更新账号数据库 + * @param userId 用户ID + * @param googleId Google ID + * @param accessToken 访问令牌 + * @param refreshToken 刷新令牌 + */ + private async updateYouTubeAccountInfo( + userId: string, + email: string, + googleId: string, + accessToken: string, + refreshToken: string, + expires_in: number + ): Promise { + try { + // 初始化YouTube客户端 + const youtube = this.initializeYouTubeClient(accessToken); + + const channelInfo = { + // id: await this.getId(), + userId: userId, + type: AccountType.YOUTUBE, + uid: googleId, + googleId: googleId, + account: "accountUrl", + nickname: "", + avatar: "", + fansCount: 0, + workCount: 0, + likeCount: 0, // YouTube API不直接提供此信息 + readCount: 0, + collectCount: 0, // YouTube API不直接提供此信息 + forwardCount: 0, // YouTube API不直接提供此信息 + commentCount: 0, // YouTube API不直接提供此信息 + // loginTime: new Date(), + updateTime: new Date(), + status: AccountStatus.USABLE, + loginCookie: "1111", // YouTube不使用cookie认证 + token: "111", // 存储访问令牌 + // groupId: defaultGrpoupId, // 默认分组,可以根据需要调整 + // income: 0 + }; + + let hasChannel = false; + // 获取当前用户的YouTube频道信息 + const response = await youtube.channels.list({ + part: 'snippet,statistics', + mine: true + }); + + if (!response.data.items || response.data.items.length === 0) { + + console.error("获取YouTube频道信息失败"); + hasChannel = false; + // 如果没有频道或获取频道信息失败,则从Google用户信息获取 + if (!hasChannel) { + console.log("无法获取YouTube频道信息,将从Google用户信息获取"); + try { + const responseGoogle = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const userInfoData = responseGoogle.data; + + // 使用Google用户信息更新账号数据 + channelInfo.account = userInfoData.email || googleId; + channelInfo.nickname = userInfoData.name || ''; + channelInfo.avatar = userInfoData.picture || ''; + + console.log("成功获取Google用户信息:", userInfoData); + } catch (error) { + console.error("获取Google用户信息失败:", error); + // 使用基本信息,确保至少有账号名称 + channelInfo.account = googleId; + channelInfo.nickname = "YouTube User"; + } + } + + } else { + hasChannel = true; + const channel = response.data.items[0]; + // 使用频道信息更新账号数据 + channelInfo.account = channel.snippet.customUrl || channel.id; + channelInfo.nickname = channel.snippet.title; + channelInfo.avatar = channel.snippet.thumbnails.default.url; + channelInfo.fansCount = parseInt(channel.statistics.subscriberCount) || 0; + channelInfo.workCount = parseInt(channel.statistics.videoCount) || 0; + channelInfo.readCount = parseInt(channel.statistics.viewCount) || 0; + + console.log("成功获取YouTube频道信息:", channel.snippet.title); + } + + console.log(channelInfo); + + // 创建或更新账号 + // const account = await this.accountModel.findOneAndUpdate( + // { googleId: googleId, type: AccountType.YOUTUBE }, + // channelInfo, + // { upsert: true, new: true } + // ); + const account = await this.accountService.addOrUpdateAccount(channelInfo); + + console.log("成功创建或更新YouTube账号:", account); + + + // 检查是否存在账号Token + const existingToken = await this.AccountTokenModel.findOne({ + accountId: googleId, + platform: TokenPlatform.YOUTUBE + }); + + if (existingToken) { + // 更新现有Token + await this.AccountTokenModel.findOneAndUpdate( + { accountId: googleId, platform: TokenPlatform.YOUTUBE }, + { refreshToken: refreshToken, updateTime: new Date() }, + ); + console.log("成功更新YouTube账号Token"); + } else { + // 创建新Token + await this.AccountTokenModel.create({ + accountId: googleId, + platform: TokenPlatform.YOUTUBE, + refreshToken: refreshToken, + expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000), + status: TokenStatus.USABLE, + createTime: new Date(), + updateTime: new Date(), + }); + console.log("成功创建YouTube账号Token"); + } + + + // // 检查账号是否存在 + // const existingAccount = await this.accountModel.findOne({ + // type: AccountType.YOUTUBE, + // googleId: googleId + // }); + + // if (existingAccount) { + // // 更新现有账号,保留原有的createTime + // channelInfo.status = existingAccount.status; + // channelInfo.groupId = existingAccount.groupId; + // channelInfo.income = existingAccount.income; + + // // 更新账号信息 + // await this.accountModel.findOneAndUpdate( + // { googleId: googleId, type: AccountType.YOUTUBE }, + // channelInfo, + // { new: true } + // ); + + // console.log("成功更新YouTube账号信息"); + + // // 更新refresh_token + // await this.AccountTokenModel.findOneAndUpdate( + // { accountId: googleId, platform: TokenPlatform.YOUTUBE }, + // { refreshToken: refreshToken, updateTime: new Date() }, + // ); + // console.log("平台:", TokenPlatform.YOUTUBE); + + // const updateInfo = { + // nickname: channel.snippet.title, + // avatar: channel.snippet.thumbnails.default.url, + // fansCount: channel.statistics.subscriberCount || 0, + // workCount: channel.statistics.videoCount || 0, + // // likeCount: 0, // YouTube API不直接提供此信息 + // readCount: channel.statistics.viewCount || 0, + // // collectCount: 0, // YouTube API不直接提供此信息 + // // forwardCount: 0, // YouTube API不直接提供此信息 + // // commentCount: 0, // YouTube API不直接提供此信息 + // // loginTime: new Date(), + // updateTime: new Date(), + // status: AccountStatus.USABLE, + // // loginCookie: '', // YouTube不使用cookie认证 + // // token: "", // 存储访问令牌 + // // groupId: 1, // 默认分组,可以根据需要调整 + // // income: 0 + // } + + // await this.accountModel.updateOne( + // { _id: existingAccount._id }, + // { $set: updateInfo } + // ); + // console.log(`已更新YouTube账号: ${updateInfo.nickname}`); + + + // } else { + // // 获取当前最大ID + // const maxIdAccount = await this.accountModel.findOne({}, { id: 1 }).sort({ id: -1 }); + // const nextId = maxIdAccount ? maxIdAccount.id + 1 : 1; + + // // 创建新账号 + // await this.accountModel.create({ + // ...channelInfo, + // id: nextId + // }); + // console.log(`已创建新YouTube账号: ${channelInfo.nickname} (${channelInfo.uid})`); + + // // 创建refresh_token和access_token + // const accountTokenInfo = { + // userId: userId, + // platform: TokenPlatform.YOUTUBE, + // refreshToken: refreshToken, + // accountId: googleId, + // status: TokenStatus.USABLE, + // createTime: new Date(), + // updateTime: new Date(), + // expiresAt: new Date((getCurrentTimestamp() + expires_in) * 1000) + // } + // await this.AccountTokenModel.create({ + // ...accountTokenInfo + // }); + // console.log(`已创建新YouTube账号Token: ${accountTokenInfo.accountId} (${accountTokenInfo.userId})`); + + // } + } catch (error) { + console.error('更新YouTube账号信息失败:', error); + // 不抛出异常,避免影响授权流程 + } + } + + /** + * 获取初始化后的YouTube API客户端 + * @param accountId 账号id + * @returns 初始化后的YouTube API客户端 + */ + async getYouTubeClient(accountId: string): Promise { + const accessToken = await this.getUserAccessToken(accountId); + if (!accessToken) { + throw new Error('No access token available, user needs to authorize'); + } + + return this.initializeYouTubeClient(accessToken); + } + + /** + * 检查用户是否已授权YouTube + * @param accountId 账号ID + * @returns 是否已授权 + */ + async isAuthorized(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + return !!accessToken; + } catch (error) { + return false; + } + } + + /** + * 撤销YouTube授权 + * @param accountId 账号ID + * @returns 撤销结果 + */ + async revokeAuthorization(accountId: string): Promise { + try { + const accessToken = await this.getUserAccessToken(accountId); + if (!accessToken) { + return true; // 已经没有授权了 + } + + // 撤销令牌 + await this.googleService.getClient().revokeToken(accessToken); + + // 删除缓存的令牌 + await this.redisService.del(`youtube:accessToken:${accountId}`); + + // 更新用户信息,移除授权信息 + await this.AccountTokenModel.updateOne( + { accountId: accountId }, + { $unset: { 'refreshToken': 1, 'expiresAt': 1 } } + ); + + return true; + } catch (error) { + console.error('Error revoking authorization:', error); + return false; + } + } + + /** + * 保存用户YouTube授权信息 + * @param userId 用户ID + * @param tokens 授权令牌 + */ + async saveUserTokens(userId: string, tokens: YouTubeAuthTokens): Promise { + try { + // 更新用户信息 + await this.userModel.updateOne( + { _id: userId }, + { + $set: { + 'googleAccount.accessToken': tokens.accessToken, + 'googleAccount.refreshToken': tokens.refreshToken, + 'googleAccount.expiresAt': tokens.expiresAt || (getCurrentTimestamp() + 3600) + } + } + ); + + // 缓存访问令牌 + const expiresIn = tokens.expiresAt ? tokens.expiresAt - getCurrentTimestamp() : 3600; + await this.redisService.setKey( + `google:accessToken:${userId}`, + { access_token: tokens.accessToken, refresh_token: tokens.refreshToken }, + expiresIn > 0 ? expiresIn : 3600 + ); + } catch (error) { + console.error('Error saving user tokens:', error); + throw new Error('Failed to save user tokens'); + } + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.controller.ts new file mode 100644 index 000000000..a2583f99c --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.controller.ts @@ -0,0 +1,555 @@ +/* + * @Author: nevin + * @Date: 2025-02-15 20:59:55 + * @LastEditTime: 2025-04-27 18:00:18 + * @LastEditors: nevin + * @Description: signIn SignIn 签到 + */ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UploadedFile, + UseInterceptors, + BadRequestException, + Res +} from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + AccessBackDto, + ArchiveAddByUtokenBodyDto, + ArchiveAddByUtokenQueryDto, +} from './dto/youtube.dto'; +import { YoutubeService } from './youtube.service'; +import { GoogleService } from '../google/google.service'; +import { GetToken, Public } from 'src/auth/auth.guard'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { YouTubeAuthService } from './youtube.auth.service'; +import { Response } from 'express'; + +@ApiTags('plat/youtube - Youtube平台') +@Controller('plat/youtube') +export class YoutubeController { + constructor( + private readonly youtubeService: YoutubeService, + private readonly youtubeAuthService: YouTubeAuthService, + ) {} + + @ApiOperation({ summary: '测试' }) + @Public() + @Get('test') + async getTest() { + const res = "success"; + return res; + } + + @ApiOperation({ summary: '获取YouTube授权URL' }) + @Get('auth/url') + async getAuthUrl( + @GetToken() systemToken: TokenInfo, + @Query('mail') mail: string) { + if (!mail) { + throw new BadRequestException('邮箱参数不能为空'); + } + // async getAuthUrl(@GetToken() token: TokenInfo) { + // // mail = token.id + // // if (!mail) { + // // throw new BadRequestException('邮箱参数不能为空'); + // // } + + return this.youtubeAuthService.getAuthorizationUrl(mail, systemToken.id); + } + + @ApiOperation({ summary: '处理YouTube授权回调' }) + @Public() + @Get('auth/callback') + async handleAuthCallback( + // @GetToken() systemToken: TokenInfo, + @Query('code') code: string, + @Query('state') state: string, + // @Query('userId') userId: string, + @Res() res: Response + ) { + + if (!code || !state) { + throw new BadRequestException('授权参数不完整'); + } + // 解析state参数以获取token + let stateData; + try { + stateData = JSON.parse(decodeURIComponent(state)); + } catch (error) { + throw new BadRequestException('无效的state参数'); + } + + const { originalState, userId, email } = stateData; + + // 现在您可以使用token变量 + console.log('Retrieved userId and originalState:', userId, originalState, email); + + try { + const results = await this.youtubeAuthService.handleAuthorizationCode(code, originalState, userId); + // 重定向到前端页面,带上token + // return res.redirect(`/auth/success?token=${token}`); + // return results + const render_msg = { + message: "授权成功! 这里是添加账号成功后的前端页面," , + datas: results + }; + + return res.render('google/index', render_msg); + + } catch (error) { + console.error('授权失败:', error); + // return res.redirect('/auth/error?message=授权失败'); + return false + } + } + + @ApiOperation({ summary: '检查是否已授权YouTube' }) + @Get('auth/check') + async checkAuth( + @GetToken() systemToken: TokenInfo, + @Query('accountId') accountId: string, + ) { + // return { + // authorized: await this.youtubeAuthService.isAuthorized(systemToken.id) + // }; + return await this.youtubeAuthService.isAuthorized(accountId) + + } + + @ApiOperation({ summary: '撤销YouTube授权' }) + @Post('auth/revoke') + async revokeAuth( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + ) { + const result = await this.youtubeAuthService.revokeAuthorization(accountId); + return result + } + + + @ApiOperation({ summary: '获取频道列表' }) + @Get('channels/list') + async getChannelsList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('handle') handle?: string, + @Query('userName') userName?: string, + @Query('id') id?: string, + @Query('mine') mine?: boolean + ) { + // 校验确保只有一个参数传递 + const params = [handle, userName, id, mine]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: handle, userName, id 或 mine'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + + return this.youtubeService.getChannelsList(accessToken, handle, userName, id, mine); + } + + @ApiOperation({ summary: '更新频道' }) + @Post('channels/update') + async channelsUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') id: string, + @Body('brandingSettings') brandingSettings?: Record, + @Body('status') status?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updateChannels(accessToken, id, brandingSettings, status); + } + + @ApiOperation({ summary: '获取频道板块列表' }) + @Get('channels/sections/list') + async getChannelSectionsList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('channelId') channelId?: string, + @Query('id') id?: string, + @Query('maxResults') maxResults?: string, + @Query('mine') mine?: boolean, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [channelId, id, mine]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: channelId, id, mine'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getChannelSectionsList(accessToken, channelId, id, mine, maxResults, pageToken); + } + + @ApiOperation({ summary: '创建频道板块' }) + @Post('channels/sections/insert') + async channelsSectionsInsert( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet?: Record, + @Body('contentDetails') contentDetails?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.insertChannelSection(accessToken, snippet, contentDetails); + } + + @ApiOperation({ summary: '更新频道版块' }) + @Post('channels/sections/update') + async channelsSectionsUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId, + @Body('snippet') snippet?: Record, + @Body('contentDetails') contentDetails?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updateChannelSection(accessToken, snippet, contentDetails); + } + + @ApiOperation({ summary: '删除频道版块' }) + @Post('channels/sections/delete') + async channelsSectionsDelete( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') channelSectionId: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.deleteChannelsSections(accessToken, channelSectionId); + } + + @ApiOperation({ summary: '获取评论列表' }) + @Get('comments/list') + async getCommentsList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('parentId') parentId: string, + @Query('id') id: string, + @Query('maxResults') maxResults?: string, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [parentId, id]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: channelId, id, mine'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getCommentsList(accessToken, parentId, id, maxResults, pageToken); + } + + @ApiOperation({ summary: '创建对现有评论的回复' }) + @Post('comments/insert') + async commentsInsert( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.insertComment(accessToken, snippet); + } + + @ApiOperation({ summary: '修改评论' }) + @Post('comments/update') + async commentsUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updateComments(accessToken, snippet); + } + + @ApiOperation({ summary: '设置一条或多条评论的审核状态' }) + @Post('comments/setModerationStatus') + async commentsSetModerationStatus( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') id: string, + @Body('moderationStatus') moderationStatus: string, + @Body('banAuthor') banAuthor?: boolean + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.setModerationStatusComments(accessToken, id, moderationStatus, banAuthor); + } + + @ApiOperation({ summary: '删除评论' }) + @Post('comments/delete') + async commentsDelete( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') id: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.deleteComments(accessToken, id); + } + + @ApiOperation({ summary: '获取评论会话列表' }) + @Get('commentThreads/list') + async getCommentThreadsList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('allThreadsRelatedToChannelId') allThreadsRelatedToChannelId: string, + @Query('id') id: string, + @Query('videoId') videoId: string, + @Query('order') order?: string, + @Query('searchTerms') searchTerms?: string, + @Query('maxResults') maxResults?: string, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [allThreadsRelatedToChannelId, id, videoId]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: allThreadsRelatedToChannelId, id, videoId'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getCommentThreadsList(accessToken, allThreadsRelatedToChannelId, id, videoId, maxResults, pageToken, order, searchTerms); + } + + @ApiOperation({ summary: '创建顶级评论' }) + @Post('commentThreads/insert') + async commentThreadsInsert( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.insertCommentThreads(accessToken, snippet); + } + + @ApiOperation({ summary: '获取视频类别列表' }) + @Get('video/categories/list') + async getVideoCategoriesList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('regionCode') regionCode?: string, + @Query('id') id?: string + ) { + // 校验确保只有一个参数传递 + const params = [regionCode, id]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: regionCode, id'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getVideoCategoriesList(accessToken, id, regionCode); + } + + @ApiOperation({ summary: '获取视频列表' }) + @Get('videos/list') + async getVideosList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('id') id?: string, + @Query('myRating') myRating?: string, + @Query('maxResults') maxResults?: string, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [id, myRating]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: id, myRating'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getVideosList(accessToken, id, myRating, maxResults, pageToken); + } + + @ApiOperation({ summary: '视频上传' }) + @UseInterceptors(FileInterceptor('file')) + @Post('videos/upload') + async videoUpload( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @UploadedFile() file: Express.Multer.File, + @Body('title') title: string, + @Body('description') description: string, + @Body('keywords') keywords?: string, + @Body('categoryId') categoryId?: string, + @Body('privacyStatus') privacyStatus?: string, + @Body('publishAt') publishAt?: Date + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.uploadVideo(token.id, accountId, accessToken, file, title, description, keywords, categoryId, privacyStatus, publishAt); + } + + @ApiOperation({ summary: '视频删除' }) + @Post('videos/delete') + async videoDelete( + @GetToken() token: TokenInfo, + @Body('id') videoId: string, + @Body('accountId') accountId: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.deleteVideo(accessToken, videoId); + } + + @ApiOperation({ summary: '更新视频' }) + @Post('videos/update') + async videoUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') videoId: string, + @Body('snippet') snippet?: Record, + @Body('status') status?: Record, + @Body('recordingDetails') recordingDetails?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updateVideo(accessToken, videoId, snippet, status, recordingDetails); + } + + @ApiOperation({ summary: '创建播放列表' }) + @Post('playlist/insert') + async playlistInsert( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet?: Record, + @Body('status') status?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.insertPlayList(accessToken, snippet, status); + } + + @ApiOperation({ summary: '获取播放列表' }) + @Get('playlist/list') + async getPlayList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('channelId') channelId?: string, + @Query('id') playListIds?: string, + @Query('mine') mine?: boolean, + @Query('maxResults') maxResults?: string, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [channelId, playListIds, mine]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: channelId, playListIds, mine'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getPlayList(accessToken, channelId, playListIds, mine, maxResults, pageToken); + } + + @ApiOperation({ summary: '更新播放列表' }) + @Post('playlist/update') + async playlistUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('playListId') playListId: string, + @Body('snippet') snippet?: Record, + @Body('status') status?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updatePlayList(accessToken, playListId, snippet, status); + } + + @ApiOperation({ summary: '删除播放列表' }) + @Post('playlist/delete') + async playlistDelete( + @GetToken() token: TokenInfo, + @Body('id') playListId: string, + @Body('accountId') accountId: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.deletePlaylist(accessToken, playListId); + } + + @ApiOperation({ summary: '获取播放列表项' }) + // @Public() + @Get('playlist/items/list') + async getPlayItemsList( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('playlistId') playlistId: string, + @Query('id') playlistItemsIds: string, + @Query('maxResults') maxResults?: string, + @Query('pageToken') pageToken?: string, + ) { + // 校验确保只有一个参数传递 + const params = [playlistId, playlistItemsIds]; + const nonEmptyParams = params.filter(param => param !== undefined && param !== null && param !== ''); + + if (nonEmptyParams.length > 1) { + throw new BadRequestException('只能选择一个参数: playlistId, playlistItemsIds'); + } + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getPlayItemsList(accessToken, playlistId, playlistItemsIds, maxResults, pageToken); + } + + @ApiOperation({ summary: '添加播放列表项' }) + @Post('playlist/items/insert') + async PlayItemsInsert( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('snippet') snippet?: Record, + @Body('contentDetails') contentDetails?: Record, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.insertPlayItems(accessToken, snippet, contentDetails); + } + + @ApiOperation({ summary: '更新播放列表项' }) + @Post('playlist/items/update') + async playItemsUpdate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') playlistItemsId: string, + @Body('snippet') snippet?: Record, + @Body('contentDetails') contentDetails?: Record, + + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.updatePlayItems(accessToken, playlistItemsId, snippet, contentDetails); + } + + @ApiOperation({ summary: '删除播放列表项' }) + @Post('playlist/items/delete') + async playItemsDelete( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') playlistItemsId: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.deletePlayItems(accessToken, playlistItemsId); + } + + @ApiOperation({ summary: '视频的点赞、踩' }) + @Post('videos/rate') + async videosRate( + @GetToken() token: TokenInfo, + @Body('accountId') accountId: string, + @Body('id') videoId: string, + @Body('rating') rating: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.videosRate(accessToken, videoId, rating); + } + + @ApiOperation({ summary: '获取视频的点赞、踩' }) + @Get('videos/rate/list') + async getVideosRating( + @GetToken() token: TokenInfo, + @Query('accountId') accountId: string, + @Query('id') videoIds: string, + ) { + const accessToken = await this.youtubeAuthService.getUserAccessToken(accountId); + return this.youtubeService.getVideosRating(accessToken, videoIds); + } + +} + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.module.ts new file mode 100644 index 000000000..91e26ddb0 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.module.ts @@ -0,0 +1,50 @@ +/* + * @Author: nevin + * @Date: 2025-03-01 19:27:26 + * @LastEditTime: 2025-04-27 17:36:19 + * @LastEditors: nevin + * @Description: bilibili Bilibili B站模块 + */ +import { Module, forwardRef } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { YoutubeController } from './youtube.controller'; +import { YoutubeService } from './youtube.service'; +import { GoogleService } from '../google/google.service'; +import { UserModule } from 'src/user/user.module'; +import { RedisModule } from 'src/lib/redis/redis.module'; +import { AuthModule } from 'src/auth/auth.module'; +import { YouTubeAuthService } from './youtube.auth.service'; +import { User, UserSchema } from 'src/db/schema/user.schema'; +import { Account, AccountSchema } from 'src/db/schema/account.schema'; +import { AccountToken, AccountTokenSchema } from 'src/db/schema/accountToken.schema'; +import { PubRecord, PubRecordSchema } from 'src/db/schema/pubRecord.schema'; +import { GoogleModule } from '../google/google.module'; +import googleConfig from 'config/google.config'; +import { ConfigModule } from '@nestjs/config'; +import { AccountModule } from 'src/modules/account/account.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + load: [googleConfig], + }), + + MongooseModule.forFeature([ + { name: User.name, schema: UserSchema }, + { name: Account.name, schema: AccountSchema }, + { name: AccountToken.name, schema: AccountTokenSchema }, + { name: PubRecord.name, schema: PubRecordSchema }, + ]), + UserModule, + RedisModule, + AuthModule, + forwardRef(() => GoogleModule), + AccountModule, + ], + controllers: [YoutubeController], + providers: [YoutubeService, + YouTubeAuthService, + GoogleService], + exports: [YoutubeService, YouTubeAuthService], +}) +export class YoutubeModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.service.ts new file mode 100644 index 000000000..c69500b2d --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/plat/youtube/youtube.service.ts @@ -0,0 +1,1316 @@ +/* + * @Author: zhangwei + * @Date: 2025-05-15 20:59:55 + * @LastEditTime: 2025-04-27 17:58:21 + * @LastEditors: zhangwei + * @Description: youtube + */ +import { Injectable, BadRequestException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import axios from 'axios'; +import { v4 as uuidv4 } from 'uuid'; + +const multer = require('multer'); +const readline = require('readline'); +const { google } = require('googleapis'); +const OAuth2 = google.auth.OAuth2; +import { GoogleService } from '../google/google.service'; +import { Readable } from 'stream'; +import { Model } from 'mongoose'; +import { PubType, PubStatus, PubRecord } from 'src/db/schema/pubRecord.schema' + +// 配置 multer 存储设置(内存存储或本地存储) +const storage = multer.memoryStorage(); // 存储在内存中 +const upload = multer({ storage: storage }); + +@Injectable() +export class YoutubeService { + private youtubeService = google.youtube('v3'); + + constructor( + private oauth2Service: GoogleService, + @InjectModel(PubRecord.name) + private readonly PubRecordModel: Model, + + ) {} + + /** + * 获取频道列表 + * @param userId 用户ID + * @param handle 频道handle + * @param userName 用户名 + * @param id 频道ID + * @param mine 是否查询自己的频道 + * @returns 频道列表 + */ + async getChannelsList(accessToken, handle, userName, id, mine) { + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + access_token: accessToken, // 使用授权的 access token + part: 'contentOwnerDetails, snippet, contentDetails, statistics, status, topicDetails', + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (handle) { + requestParams.forHandle = handle; // 如果提供了 handle, 使用 handle + } else if (userName) { + requestParams.forUsername = userName; // 如果提供了 userName, 使用 userName + } else if (mine !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (mine) { + requestParams.mine = true; // 请求当前登录用户的频道 + } + } + + try { + const response = await this.youtubeService.channels.list(requestParams); + + const channels = response.data; + console.log(channels); + if (channels.length === 0) { + console.log('No channel found.'); + return []; + } else { + console.log(`This channel's ID is ${channels}`); + return channels; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 更新频道 + * @param accessToken + * @param ChannelId 频道ID + * @param brandingSettings 品牌设置 + * @param status 状态 + * @returns 更新结果 + */ + async updateChannels(accessToken, ChannelId, brandingSettings, status) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody: any = { + id: ChannelId, + // snippet: { + // playlistId: playlistId, + // resourceId: resourceId, + // }, + // contentDetails: {}, + }; + + // 如果传递了 note,则添加到请求体 + if (brandingSettings !== undefined) { + requestBody.brandingSettings = brandingSettings; + } + if (status !== undefined) { + requestBody.status = status; + } + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.channelSections.update( + { + auth: oauth2Client, + part: 'brandingSettings', + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Channels update successfully:', response.data); + return response.data; + } else { + return 'Channels updated failed'; + } + } catch (error) { + console.error('Error Channels update:', error); + return error; + } + + } + + /** + * 获取频道板块列表 + * @param accessToken + * @param channelId 频道ID + * @param id 板块ID + * @param mine 是否查询自己的板块 + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 频道板块列表 + */ + async getChannelSectionsList(accessToken, channelId, id, mine, maxResults, pageToken) { + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + access_token: accessToken, // 使用授权的 access token + part: 'contentDetails, id, snippet', + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (channelId) { + requestParams.channelId = channelId; // 如果提供了 handle, 使用 handle + } else if (mine !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (mine) { + requestParams.mine = true; // 请求当前登录用户的频道 + } + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.channelSections.list(requestParams); + const sections = response.data; + console.log(sections); + if (sections.length === 0) { + console.log('No sections found.'); + return []; + } else { + console.log(`This sections's ID is ${sections}.`); + return sections; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 创建频道板块。 + * + * @param snippet 元数据 + * @param contentDetails 内容详情 + * @returns 创建结果 + */ + async insertChannelSection(accessToken, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: snippet, + contentDetails: contentDetails, + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.channelSections.insert( + { + auth: oauth2Client, + part: 'snippet,id, contentDetails', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Channel Section insert successfully:', response.data); + return response.data; + } else { + return 'Channel Section insert failed'; + } + } catch (error) { + console.error('Error Channel Section insert:', error); + return error; + } + } + + /** + * 更新频道板块。 + * @param snippet 元数据 + * @param contentDetails 内容详情 + * @returns 创建结果 + */ + async updateChannelSection(accessToken, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: snippet, + contentDetails: contentDetails, + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.channelSections.update( + { + auth: oauth2Client, + part: 'snippet,id, contentDetails', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 删除频道板块 + * @param channelSectionId 频道板块ID +* @returns 删除结果 + */ + async deleteChannelsSections(accessToken, channelSectionId) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const response = await this.youtubeService.channelSections.delete({ + auth: oauth2Client, + id: channelSectionId, + }); + console.log('Video deleted:', response.data); + } catch (error) { + console.error('Error deleting video:', error); + return error + } + } + + /** + * 获取频道板块列表。 + * @param parentId 父评论ID + * @param id 评论ID + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 评论列表 + */ + async getCommentsList(accessToken, parentId, id, maxResults, pageToken) { + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + access_token: accessToken, // 使用授权的 access token + part: 'id, snippet', + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (parentId) { + requestParams.channelId = parentId; // 如果提供了 handle, 使用 handle + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.comments.list(requestParams); + const sections = response.data; + console.log(sections); + if (sections.length === 0) { + console.log('No sections found.'); + return []; + } else { + console.log(`This sections's ID is ${sections}.`); + return sections; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 创建对现有评论的回复 + * @param snippet 元数据 + * @returns 创建结果 + */ + async insertComment(accessToken, snippet) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: snippet + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.comments.insert( + { + auth: oauth2Client, + part: 'snippet,id', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 更新评论。 + * @param snippet 元数据 + * @returns 创建结果 + */ + async updateComments(accessToken, snippet) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: snippet + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.comments.update( + { + auth: oauth2Client, + part: 'snippet,id', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 设置一条或多条评论的审核状态。 + * @param id 评论ID + * @param moderationStatus 审核状态 + * @param banAuthor 是否禁止作者 + * @returns 设置结果 + */ + async setModerationStatusComments(accessToken, id, moderationStatus, banAuthor) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + id: id, + moderationStatus:moderationStatus, // heldForReview 等待管理员审核 published - 清除要公开显示的评论。 rejected - 不显示该评论 + banAuthor: banAuthor // 自动拒绝评论作者撰写的任何其他评论 将作者加入黑名单 + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.comments.setModerationStatus( + { + auth: oauth2Client, + part: 'snippet,id', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 删除评论 + * @param id 评论ID + * @returns 删除结果 + */ + async deleteComments(accessToken, id) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const response = await this.youtubeService.comments.delete({ + auth: oauth2Client, + id: id, + }); + console.log('Video deleted:', response.data); + } catch (error) { + console.error('Error deleting video:', error); + return error + } + } + + + /** + * 获取评论会话列表。 + */ + async getCommentThreadsList(accessToken, allThreadsRelatedToChannelId, id, videoId, maxResults, pageToken, order, searchTerms) { + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + access_token: accessToken, // 使用授权的 access token + part: 'id, snippet', + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (allThreadsRelatedToChannelId) { + requestParams.allThreadsRelatedToChannelId = allThreadsRelatedToChannelId; // 如果提供了 handle, 使用 handle + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + else if (videoId) { + requestParams.videoId = videoId; // 如果提供了 handle, 使用 handle + } + else if (order) { + requestParams.order = order; // 如果提供了 handle, 使用 handle + } + else if (searchTerms) { + requestParams.searchTerms = searchTerms; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.commentThreads.list(requestParams); + const sections = response.data; + console.log(sections); + if (sections.length === 0) { + console.log('No sections found.'); + return []; + } else { + console.log(`This sections's ID is ${sections}.`); + return sections; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + + /** + * 创建顶级评论 + */ + async insertCommentThreads(accessToken, snippet) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: snippet + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.commentThreads.insert( + { + auth: oauth2Client, + part: 'snippet,id', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 获取视频类别列表。 + */ + async getVideoCategoriesList(accessToken, id, regionCode) { + + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + access_token: accessToken, // 使用授权的 access token + part: 'snippet', + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (regionCode) { + requestParams.regionCode = regionCode; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.videoCategories.list(requestParams); + + const categories = response.data; + console.log(categories); + if (categories.length === 0) { + console.log('No categories found.'); + return []; + } else { + console.log(`This categories's ID is ${categories}.`); + return categories; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 获取视频列表。 + * @param id 视频ID + * @param chart 图表类型 + * @param maxResults 最大结果数 + * @param pageToken 分页令牌 + * @returns 视频列表 + */ + async getVideosList(accessToken, id, myRating, maxResults, pageToken) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + auth: oauth2Client, + part: 'snippet,contentDetails,statistics, id, status, topicDetails', + // id: ids + }; + + // 根据参数选择 `id` 或 `forUsername` + if (id) { + requestParams.id = id; // 如果提供了 id, 使用 id + } else if (myRating !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (myRating) { + requestParams.myRating = myRating; // 请求当前登录用户的频道 + } + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.videos.list(requestParams); + + const infos = response.data; + console.log(infos); + if (infos.length === 0) { + console.log('No categories found.'); + return []; + } else { + console.log(`This infos's ID is ${infos}.`); + return infos; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 上传视频。 + * @param file 视频文件 + * @param accountId 账号ID + * @param title 标题 + * @param description 描述 + * @param keywords 关键词 + * @param categoryId 分类ID + * @param privacyStatus 状态(公开?私密) + * @returns 视频ID + */ + async uploadVideo(userId, accountId, accessToken, file, title, description, keywords, categoryId, privacyStatus, publishAt) { + // 获取当前最大的 id + const maxRecord = await this.PubRecordModel.findOne().sort({ id: -1 }); + const newId = maxRecord ? maxRecord.id + 1 : 1; + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const channelInfo = await this.youtubeService.channels.list({ + part: ['snippet'], + mine: true, + auth: oauth2Client, + }); + + if (!channelInfo.data.items || channelInfo.data.items.length === 0) { + throw new Error('未检测到可用的 YouTube 频道,请先创建频道'); + } + + // 可以上传 + } catch (err) { + if (err.errors?.[0]?.reason === 'youtubeSignupRequired') { + throw new Error('当前账号未启用 YouTube,请先创建频道'); + } + } + + + // 准备视频的元数据 + const fileStream = Readable.from(file.buffer); // 使用文件的 Buffer 转为可读取流 + const fileSize = file.size; // 获取文件大小 + + // 构造请求体 + let requestBody: any = { + snippet: { + title: title, + description: description, + // tags: keywords ? keywords.split(',') : [], + tags: keywords ? keywords : [], + categoryId: categoryId || '22', // 默认 categoryId 为 '22',如果没有指定 + }, + status: { + privacyStatus: privacyStatus, // 可以是 'public', 'private', 'unlisted' + }, + }; + + + // 创建发布记录 + let newData: any = { + userId: userId, + type: PubType.VIDEO, + title: title, + desc: description, + accountId: accountId, + status:PubStatus.UNPUBLISH, + timingTime: publishAt, + publishTime: new Date() + } + + if (publishAt) { + requestBody.status.publishAt = publishAt; // 如果提供了 publishAt 则使用 publishAt + } + + console.log(requestBody); + + await this.PubRecordModel.create({ + ...newData, + id: newId, + }); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.videos.insert( + { + auth: oauth2Client, + part: 'snippet,status, id, contentDetails', + requestBody, + media: { + body: fileStream, // 上传的文件流 + }, + }, + { + onUploadProgress: (e) => { + const progress = Math.round((e.bytesRead / fileSize) * 100); + console.log(`Uploading... ${progress}%`); + }, + } + ); + + // const response = { "data": { + // "id": "7RckZHFBu7A" + // },} + // 返回上传的视频 ID + if (response.data.id) { + console.log('Video uploaded successfully, video ID:', response.data); + // return { videoId: response.data.id }; + // 更新发布记录 + await this.PubRecordModel.updateOne({ id:newId }, { + status: PubStatus.RELEASED, + publishTime: response.data.snippet.publishedAt, + coverPath: response.data.snippet.thumbnails.url + }); + + return response.data + } else { + await this.PubRecordModel.updateOne({ id:newId }, { status: PubStatus.FAIL }); + return 'Video upload failed'; + } + } catch (error) { + await this.PubRecordModel.updateOne({ id:newId }, { status: PubStatus.FAIL }); + console.error('Error uploading video:', error); + return error; + } + + } + + /** + * 删除视频 + */ + async deleteVideo(accessToken, videoId) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const response = await this.youtubeService.videos.delete({ + auth: oauth2Client, + id: videoId, + }); + // await this.PubRecordModel.updateOne({ id:videoId }, { status: PubStatus.FAIL }); + console.log('Video deleted:', response.data); + } catch (error) { + console.error('Error deleting video:', error); + return error + } + } + + /** + * 更新视频。 + */ + async updateVideo(accessToken, videoId, snippet, status, recordingDetails) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + const requestBody: any = { + id: videoId, + snippet: snippet, + status: status, + recordingDetails: recordingDetails + }; + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.videos.update( + { + auth: oauth2Client, + part: 'snippet,status,id', + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + + } + + + /** + * 创建播放列表。 + */ + async insertPlayList(accessToken, snippet, status) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + // const requestBody = { + // snippet: { + // title: title, + // description: description + // }, + // status: { + // privacyStatus: privacyStatus, // 可以是 'public', 'private', 'unlisted' + // }, + // }; + const requestBody = { + snippet: snippet, + status: status, + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.playlists.insert( + { + auth: oauth2Client, + part: 'snippet,status, id, contentDetails', + requestBody + } + ); + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 获取播放列表。 + */ + async getPlayList(accessToken, channelId, playListIds, mine, maxResults, pageToken) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + auth: oauth2Client, + part: 'snippet,contentDetails, id, status, topicDetails, player', + // id: ids + }; + + // 根据参数选择 `id` 或 `forUsername` + if (playListIds) { + requestParams.ids = playListIds; // 如果提供了 id, 使用 id + } else if (channelId) { + requestParams.channelId = channelId; // 如果提供了 handle, 使用 handle + } else if (mine !== undefined) { + // 如果 mine 被传递且是布尔值, 可以检查是否为 `true` + if (mine) { + requestParams.mine = true; // 请求当前登录用户的频道 + } + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.playlist.list(requestParams); + + const infos = response.data; + console.log(infos); + if (infos.length === 0) { + console.log('No categories found.'); + return []; + } else { + console.log(`This infos's ID is ${infos}.`); + return infos; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } +} + + /** + * 更新播放列表。 + */ + async updatePlayList(accessToken, playListId, snippet, status) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody: any = { + id: playListId, // 必填 + snippet: snippet, // 类型断言 + status: status // 类型断言 + }; + + // 根据参数选择 `title`、`description`、`privacyStatus` 或 `podcastStatus` + + // if (description) { + // requestBody.snippet.description = description; // 如果提供了 id, 使用 id + // } + + // if (privacyStatus || podcastStatus) { + // if (privacyStatus) { + // requestBody.status.privacyStatus = privacyStatus; // 如果提供了 id, 使用 id + // } + // if (podcastStatus) { + // requestBody.status.podcastStatus = podcastStatus; // 如果提供了 id, 使用 id + // } + // } + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.playlists.update( + { + auth: oauth2Client, + part: 'snippet,status,id', + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + + } + + /** + * 删除播放列表 + */ + async deletePlaylist(accessToken, playListId) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const response = await this.youtubeService.playlists.delete({ + auth: oauth2Client, + id: playListId, + }); + console.log('Video deleted:', response.data); + } catch (error) { + console.error('Error deleting video:', error); + return error + } + } + + /** + * 将视频添加到播放列表中 + */ + async addVideoToPlaylist(accessToken, videoId, playlistId) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody = { + snippet: { + playlistId: playlistId, + resourceId: { + kind: 'youtube#video', + videoId: videoId, + }, + } + }; + + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.playlistItems.insert( + { + auth: oauth2Client, + part: 'snippet,status, id, contentDetails', + requestBody + } + ); + + // const response = { "data": { + // "id": "7RckZHFBu7A" + // },} + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + throw new Error('Video upload failed'); + } + } catch (error) { + console.error('Error uploading video:', error); + throw error; + } + + } + + + /** + * 获取播放列表项。 + */ + async getPlayItemsList(accessToken, playlistId, itemsIds, maxResults, pageToken) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + auth: oauth2Client, + part: 'snippet, contentDetails, id, status', + // id: ids + }; + + // 根据参数选择 `id` 或 `forUsername` + if (itemsIds) { + requestParams.ids = itemsIds; // 如果提供了 id, 使用 id + } else if (playlistId) { + requestParams.playlistId = playlistId; // 如果提供了 handle, 使用 handle + } else if (maxResults) { + requestParams.maxResults = maxResults; // 如果提供了 handle, 使用 handle + } else if (pageToken) { + requestParams.pageToken = pageToken; // 如果提供了 handle, 使用 handle + } + + try { + const response = await this.youtubeService.playlistItems.list(requestParams); + + const infos = response.data; + console.log(infos); + if (infos.length === 0) { + console.log('No categories found.'); + return []; + } else { + console.log(`This infos's ID is ${infos}.`); + return infos; + } + } catch (err) { + console.log('The API returned an error: ' + err); + return err; + } + } + + /** + * 插入播放列表项。 + */ + async insertPlayItems(accessToken, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // // 构造请求体 + // const requestBody: any = { + // snippet: { + // playlistId: playlistId, + // resourceId: resourceId, + // }, + // contentDetails: {}, + // }; + + // // 如果传递了 position,则添加到请求体 + // if (position !== undefined) { + // requestBody.snippet.position = position; + // } + + // // 如果传递了 note,则添加到请求体 + // if (note !== undefined) { + // requestBody.contentDetails.note = note; + // } + const requestBody: any = { + snippet: snippet, + contentDetails: contentDetails, + }; + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.playlistItems.insert( + { + auth: oauth2Client, + part: 'snippet,status, id, contentDetails', + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + } + + /** + * 更新播放列表项。 + */ + async updatePlayItems(accessToken, playlistItemsId, snippet, contentDetails) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + const requestBody: any = { + id: playlistItemsId, + snippet: snippet, + contentDetails: contentDetails, + }; + console.log(requestBody); + + // 调用 YouTube API 上传视频 + const response = await this.youtubeService.playlistItems.update( + { + auth: oauth2Client, + part: 'snippet,status,id', + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video upload failed'; + } + } catch (error) { + console.error('Error uploading video:', error); + return error; + } + + } + +/** + * 删除播放列表项 + */ + async deletePlayItems(accessToken, playlistItemsId) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + try { + const response = await this.youtubeService.playlistItems.delete({ + auth: oauth2Client, + id: playlistItemsId, + }); + console.log('Video deleted:', response.data); + } catch (error) { + console.error('Error deleting video:', error); + return error + } + } + + /** + * 对视频的点赞、踩。 + */ + async videosRate(accessToken, videoId, rating) { + try { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 构造请求体 + const requestBody: any = { + id: videoId, + rating: rating // like | dislike | none + }; + + console.log(requestBody); + + // 调用 API 进行点赞或踩 + const response = await this.youtubeService.videos.rate( + { + auth: oauth2Client, + requestBody + } + ); + + // 返回上传的视频 ID + if (response.data) { + console.log('Playlist insert successfully:', response.data); + return response.data; + } else { + return 'Video rating failed'; + } + } catch (error) { + console.error('Error rating video:', error); + this.handleApiError(error); + } + + } + + /** + * 获取视频的点赞、踩。 + */ + async getVideosRating(accessToken, videoIds) { + // 设置 OAuth2 客户端凭证 + this.oauth2Service.setCredentials(accessToken); + const oauth2Client = this.oauth2Service.getClient(); + + // 根据传入的参数来选择一个有效的请求参数 + let requestParams: any = { + auth: oauth2Client, + id: videoIds + }; + + try { + const response = await this.youtubeService.videos.getRating(requestParams); + + const infos = response.data; + console.log(infos); + if (infos.length === 0) { + console.log('No categories found.'); + return []; + } else { + console.log(`This infos's ID is ${infos}.`); + return infos; + } + } catch (err) { + this.handleApiError(err); + } +} + + /** + * 处理API错误 + * @param error 错误对象 + */ + private handleApiError(error: any) { + console.error('YouTube API Error:', error); + + if (error.response) { + // API响应错误 + const { status, data } = error.response; + if (status === 401) { + throw new BadRequestException('授权已过期,请重新授权'); + } else if (status === 403) { + throw new BadRequestException('权限不足,无法执行此操作'); + } else if (data && data.error && data.error.message) { + throw new BadRequestException(`YouTube API错误: ${data.error.message}`); + } + } + + throw new BadRequestException('YouTube API请求失败'); + } + +} + diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/publish.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/publish.dto.ts new file mode 100644 index 000000000..a79b7602e --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/publish.dto.ts @@ -0,0 +1,114 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: publish + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDate, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { AccountType } from 'src/db/schema/account.schema'; +import { PubType, PubStatus } from 'src/db/schema/pubRecord.schema'; + +export class CreatePublishDto { + @ApiProperty({ + title: '类型', + required: true, + enum: PubType, + description: '类型', + }) + @IsEnum(PubType, { message: '类型' }) + @Expose() + readonly type: PubType; + + @ApiProperty({ title: '标题', required: true }) + @IsString({ message: '标题' }) + @Expose() + readonly title: string; + + @ApiProperty({ title: '描述', required: true }) + @IsString({ message: '描述' }) + @Expose() + readonly desc: string; + + @ApiProperty({ title: '账户ID', required: true }) + @IsNumber({ allowNaN: false }, { message: '账户ID' }) + @Expose() + readonly accountId: number; + + @ApiProperty({ title: '视频路径', required: false }) + @IsString({ message: '视频路径' }) + @IsOptional() + @Expose() + readonly videoPath?: string; + + @ApiProperty({ title: '定时发布日期', required: false }) + @IsDate({ message: '定时发布日期必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => new Date(value)) + readonly timingTime?: Date; + + @ApiProperty({ title: '封面路径,展示给前台用', required: false }) + @IsString({ message: '封面路径,展示给前台用' }) + @IsOptional() + @Expose() + readonly coverPath?: string; + + @ApiProperty({ title: '通用封面路径', required: false }) + @IsString({ message: '通用封面路径' }) + @IsOptional() + @Expose() + readonly commonCoverPath?: string; + + @ApiProperty({ title: '发布日期', required: false }) + @IsDate({ message: '发布日期必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => new Date(value)) + readonly publishTime?: Date; + + @ApiProperty({ + title: '发布状态', + required: false, + enum: PubStatus, + description: '发布状态', + }) + @IsEnum(PubStatus, { message: '发布状态' }) + @IsOptional() + @Expose() + readonly status?: PubStatus; +} + +export class PubRecordListDto { + @ApiProperty({ + title: '账户类型', + required: false, + enum: AccountType, + description: '账户类型', + }) + @IsEnum(AccountType, { message: '账户类型' }) + @IsOptional() + @Expose() + readonly type?: AccountType; + + @ApiProperty({ title: '创建时间区间', required: false }) + @IsArray({ message: '创建时间区间必须是一个数组' }) + @ArrayMinSize(2, { message: '创建时间区间必须包含两个日期' }) + @ArrayMaxSize(2, { message: '创建时间区间必须包含两个日期' }) + @IsDate({ each: true, message: '创建时间区间中的每个元素必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => value ? value.map((v: string) => new Date(v)) : undefined) + readonly time?: [Date, Date]; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/video.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/video.dto.ts new file mode 100644 index 000000000..3260ea4c2 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/video.dto.ts @@ -0,0 +1,65 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: publish + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDate, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { CreateWorkDataDto } from './workData.dto'; + +export class PubRecordIdDto { + @ApiProperty({ title: '发布记录ID', required: true }) + @IsNumber({ allowNaN: false }, { message: '发布记录ID' }) + @Expose() + readonly pubRecordId: number; +} + +export class VideoPulIdDto { + @ApiProperty({ title: 'ID', required: true }) + @IsNumber({ allowNaN: false }, { message: 'ID' }) + @Expose() + readonly id: number; +} + +export class VideoPulListDto { + @ApiProperty({ title: '发布记录ID', required: false }) + @IsNumber({ allowNaN: false }, { message: '发布记录ID' }) + @IsOptional() + @Expose() + readonly pubRecordId?: number; + + @ApiProperty({ title: '创建时间区间', required: false }) + @IsArray({ message: '创建时间区间必须是一个数组' }) + @ArrayMinSize(2, { message: '创建时间区间必须包含两个日期' }) + @ArrayMaxSize(2, { message: '创建时间区间必须包含两个日期' }) + @IsDate({ each: true, message: '创建时间区间中的每个元素必须是有效的日期' }) + @IsOptional() + @Expose() + @Transform(({ value }) => value.map((v: string) => new Date(v))) + readonly time?: [Date, Date]; + + @ApiProperty({ title: '标题', required: false }) + @IsString({ message: '标题必须是一个字符串' }) + @IsOptional() + @Expose() + readonly title?: string; +} + +export class CreateVideoPulDto extends CreateWorkDataDto { + @ApiProperty({ title: '视频路径', required: false }) + @IsString({ message: '视频路径必须是一个字符串' }) + @IsOptional() + @Expose() + readonly videoPath: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/workData.dto.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/workData.dto.ts new file mode 100644 index 000000000..acea418b8 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/dto/workData.dto.ts @@ -0,0 +1,54 @@ +/* + * @Author: nevin + * @Date: 2024-08-19 15:58:47 + * @LastEditTime: 2025-03-17 12:41:12 + * @LastEditors: nevin + * @Description: WorkData + */ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsOptional, IsString } from 'class-validator'; +import { AccountType } from 'src/db/schema/account.schema'; +import { + DiffParmasType, + ILableValue, + ILocationDataItem, + PubStatus, + VisibleTypeEnum, +} from 'src/db/schema/workData.schema'; + +export class CreateWorkDataDto { + dataId?: string; + userId: string; + lastStatsTime?: Date; // 最后统计时间 + previewVideoLink: string; // 预览地址,这个值是发布完成手动拼接的 + pubRecordId: number; // 发布记录id,对应PubRecord表id + accountId: number; // 账号id + type: AccountType; // 平台类型 + publishTime?: Date; // 发布时间 + otherInfo?: Record; // 其他信息 + failMsg?: string; // 发布失败原因(如果失败) + status: PubStatus; + readCount: number; + likeCount: number; + collectCount: number; + forwardCount: number; + commentCount: number; + income: number; + title?: string; // 标题 + desc?: string; // 简介,简介中不该包含话题,如果有需要每个平台再自己做处理。 + coverPath?: string; // 封面路径,机器的本地路径 + mixInfo?: ILableValue; // 合集 + topics: string[]; // 话题 格式:['话题1', '话题2'],不该包含 ‘#’ + location?: ILocationDataItem; // 位置 + diffParams?: DiffParmasType; + visibleType?: VisibleTypeEnum; + timingTime?: Date; // 定时发布日期 + cookies?: Record; + + // @ApiProperty({ title: '视频路径', required: false }) + // @IsString({ message: '视频路径必须是一个字符串' }) + // @IsOptional() + // @Expose() + // readonly videoPath: string; +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.controller.ts new file mode 100644 index 000000000..2a83f867a --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.controller.ts @@ -0,0 +1,102 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: 发布 + */ +import { Body, Controller, Get, Param, Post, Query, Delete } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { GetToken } from 'src/auth/auth.guard'; +import { PublishService } from './publish.service'; +import { CreatePublishDto, PubRecordListDto } from './dto/publish.dto'; +import { TableDto } from 'src/global/dto/table.dto'; +import { PubStatus } from 'src/db/schema/pubRecord.schema'; + +@ApiTags('发布') +@Controller('publish') +export class PublishController { + constructor(private readonly pubRecordService: PublishService) {} + + @ApiOperation({ + description: '创建发布记录', + summary: '创建发布记录', + }) + @Post() + async createPubRecord( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: CreatePublishDto, + ) { + console.log(body) + const res = await this.pubRecordService.createPubRecord({ + userId: token.id, + ...body, + }); + return res; + } + + @ApiOperation({ + description: '获取发布记录列表', + summary: '获取发布记录列表', + }) + @Get('list/:pageNo/:pageSize') + async getPubRecordList( + @GetToken() token: TokenInfo, + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: PubRecordListDto, + ) { + const res = await this.pubRecordService.getPubRecordList( + token.id, + param, + query, + ); + return res; + } + + @ApiOperation({ + description: '获取草稿列表', + summary: '获取草稿列表', + }) + @Get('drafts/list/:pageNo/:pageSize') + async getPubRecordDraftsList( + @GetToken() token: TokenInfo, + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: PubRecordListDto, + ) { + const res = await this.pubRecordService.getPubRecordDraftsList( + token.id, + param, + query, + ); + return res; + } + + @ApiOperation({ + description: '更新发布记录状态', + summary: '更新发布记录状态', + }) + @Post('status/:id') + async updatePubRecordStatus( + @GetToken() token: TokenInfo, + @Param('id', new ParamsValidationPipe()) id: number, + @Body('status', new ParamsValidationPipe()) status: PubStatus, + ) { + const res = await this.pubRecordService.updatePubRecordStatus(id, status); + return res; + } + + @ApiOperation({ + description: '删除发布记录', + summary: '删除发布记录', + }) + @Delete(':id') + async deletePubRecord( + @GetToken() token: TokenInfo, + @Param('id', new ParamsValidationPipe()) id: number, + ) { + const res = await this.pubRecordService.deletePubRecordById(id); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.module.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.module.ts new file mode 100644 index 000000000..1337389e3 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.module.ts @@ -0,0 +1,27 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:07 + * @LastEditTime: 2024-09-05 15:18:26 + * @LastEditors: nevin + * @Description: 发布模块 + */ +import { Module } from '@nestjs/common'; +import { PublishService } from './publish.service'; +import { PublishController } from './publish.controller'; +import { MongooseModule } from '@nestjs/mongoose'; +import { PubRecord, PubRecordSchema } from 'src/db/schema/pubRecord.schema'; +import { VideoService } from './video/video.service'; +import { VideoController } from './video/video.controller'; +import { Video, VideoSchema } from 'src/db/schema/video.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: PubRecord.name, schema: PubRecordSchema }, + { name: Video.name, schema: VideoSchema }, + ]), + ], + providers: [PublishService, VideoService], + controllers: [PublishController, VideoController], +}) +export class PublishModule {} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.service.ts new file mode 100644 index 000000000..0394a5ab5 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/publish.service.ts @@ -0,0 +1,120 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: PubRecord + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { PubRecord, PubStatus } from 'src/db/schema/pubRecord.schema'; +import { TableDto } from 'src/global/dto/table.dto'; +import { PubRecordListDto } from './dto/publish.dto'; + +@Injectable() +export class PublishService { + constructor( + @InjectModel(PubRecord.name) + private readonly PubRecordModel: Model, + ) {} + + async createPubRecord(newData: Partial) { + // 获取当前最大的 id + const maxRecord = await this.PubRecordModel.findOne().sort({ id: -1 }); + const newId = maxRecord ? maxRecord.id + 1 : 1; + + return await this.PubRecordModel.create({ + ...newData, + id: newId, + }); + } + + /** + * 获取发布记录列表 + * @param userId + * @param page + * @returns + */ + async getPubRecordList( + userId: string, + page: TableDto, + query: PubRecordListDto, + ): Promise<{ + list: PubRecord[]; + totalCount: number; + }> { + const filters: RootFilterQuery = { + userId, + ...(query.type !== undefined && { type: query.type }), + ...(query.time !== undefined && + query.time.length === 2 && { + createdAt: { $gte: query.time[0], $lte: query.time[1] }, + }), + }; + const list = await this.PubRecordModel.find(filters) + .skip((page.pageNo - 1) * page.pageSize) + .limit(page.pageSize) + .sort({ createdAt: -1 }); + + const totalCount = await this.PubRecordModel.countDocuments(filters); + + return { + list, + totalCount, + }; + } + + /** + * 获取发布记录列表 + * @param userId + * @param page + * @returns + */ + async getPubRecordDraftsList( + userId: string, + page: TableDto, + query: PubRecordListDto, + ): Promise<{ + list: PubRecord[]; + totalCount: number; + }> { + const filters: RootFilterQuery = { + userId, + status: PubStatus.UNPUBLISH, + ...(query.type !== undefined && { type: query.type }), + ...(query.time !== undefined && + query.time.length === 2 && { + createdAt: { $gte: query.time[0], $lte: query.time[1] }, + }), + }; + const list = await this.PubRecordModel.find(filters) + .skip((page.pageNo - 1) * page.pageSize) + .limit(page.pageSize) + .sort({ createdAt: -1 }); + + const totalCount = await this.PubRecordModel.countDocuments(filters); + + return { + list, + totalCount, + }; + } + + // 获取发布记录信息 + async getPubRecordInfo(id: number) { + return await this.PubRecordModel.findOne({ id }); + } + + // 更新发布记录的状态 + async updatePubRecordStatus(id: number, status: PubStatus): Promise { + const res = await this.PubRecordModel.updateOne({ id }, { status }); + return res.modifiedCount > 0; + } + + // 删除发布记录 + async deletePubRecordById(id: number): Promise { + const res = await this.PubRecordModel.deleteOne({ id }); + return res.deletedCount > 0; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.controller.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.controller.ts new file mode 100644 index 000000000..cb8659df9 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.controller.ts @@ -0,0 +1,119 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:20 + * @LastEditTime: 2024-12-23 12:45:22 + * @LastEditors: nevin + * @Description: video + */ +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ParamsValidationPipe } from 'src/validation.pipe'; +import { TokenInfo } from 'src/auth/interfaces/auth.interfaces'; +import { GetToken } from 'src/auth/auth.guard'; +import { VideoService } from './video.service'; +import { AccountType } from 'src/db/schema/account.schema'; +import { TableDto } from 'src/global/dto/table.dto'; +import { + CreateVideoPulDto, + PubRecordIdDto, + VideoPulIdDto, + VideoPulListDto, +} from '../dto/video.dto'; + +@ApiTags('视频发布') +@Controller('video') +export class VideoController { + constructor(private readonly videoService: VideoService) {} + + @ApiOperation({ + description: '获取发布记录', + summary: '获取发布记录', + }) + @Get('list/:pubRecordId') + async getVideoRecord( + @Param(new ParamsValidationPipe()) param: PubRecordIdDto, + ) { + return this.videoService.getVideoPulListByPubRecordId(param.pubRecordId); + } + + @ApiOperation({ + description: '获取发布记录列表', + summary: '获取发布记录列表', + }) + @Get('list/:pageNo/:pageSize') + async getVideoPulList( + @GetToken() token: TokenInfo, + @Param(new ParamsValidationPipe()) param: TableDto, + @Query(new ParamsValidationPipe()) query: VideoPulListDto, + ) { + const res = await this.videoService.getVideoPulList(token.id, param, query); + return res; + } + + @ApiOperation({ + description: '获取视频发布信息', + summary: '获取视频发布信息', + }) + @Get('info/:id') + async getVideoPulInfo( + @Param(new ParamsValidationPipe()) param: VideoPulIdDto, + ) { + const res = await this.videoService.getVideoPulInfo(param.id); + return res; + } + + @ApiOperation({ + description: '创建视频发布记录', + summary: '创建视频发布记录', + }) + @Post('') + async newVideoPul( + @GetToken() token: TokenInfo, + @Body(new ParamsValidationPipe()) body: CreateVideoPulDto, + ) { + const res = await this.videoService.newVideoPul(token.id, body); + return res; + } + + @ApiOperation({ + description: '获取视频发布记录类型统计', + summary: '获取视频发布记录类型统计', + }) + @Get('count') + async getVideoPulTypeCount( + @GetToken() token: TokenInfo, + @Query(new ParamsValidationPipe()) + query: { + type?: AccountType; + }, + ) { + const res = await this.videoService.getVideoPulTypeCount( + token.id, + query.type, + ); + return res; + } + + @ApiOperation({ + description: '更新视频发布数据', + summary: '更新视频发布数据', + }) + @Post('up/:id') + async updateVideoPul( + @Param(new ParamsValidationPipe()) param: VideoPulIdDto, + @Body(new ParamsValidationPipe()) body: CreateVideoPulDto, + ) { + const res = await this.videoService.updateVideoPul(param.id, body); + return res; + } + + @ApiOperation({ + description: '删除发布记录', + summary: '删除发布记录', + }) + @Post('del/:id') + async delVideoPul(@Param(new ParamsValidationPipe()) param: VideoPulIdDto) { + const res = await this.videoService.deleteVideoPulByPubRecordId(param.id); + return res; + } +} diff --git a/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.service.ts b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.service.ts new file mode 100644 index 000000000..314836548 --- /dev/null +++ b/project/aitoearn-wxplat/project/aitoearn-electron/server/src/modules/publish/video/video.service.ts @@ -0,0 +1,144 @@ +/* + * @Author: nevin + * @Date: 2024-06-17 19:19:15 + * @LastEditTime: 2024-09-05 15:19:25 + * @LastEditors: nevin + * @Description: Video + */ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model, RootFilterQuery } from 'mongoose'; +import { AccountType } from 'src/db/schema/account.schema'; +import { Video } from 'src/db/schema/video.schema'; +import { TableDto } from 'src/global/dto/table.dto'; +import { VideoPulListDto } from '../dto/video.dto'; + +@Injectable() +export class VideoService { + constructor( + @InjectModel(Video.name) + private readonly videoModel: Model