From 21b3f8549a4815d3ca190563ede98ca5bd193f7a Mon Sep 17 00:00:00 2001 From: Finley Ge <32237950+FinleyGe@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:13:43 +0800 Subject: [PATCH] refactor: merge standardConstants and standard in team plan (#6549) * refactor: merge standardConstants and standard in team plan * Update packages/service/support/wallet/sub/utils.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: remove type assertion * chore: type * test: test buildStandardPlan * fix: type * perf: code perf * add test code --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: archer <545436317@qq.com> --- .../workflows/build-sandbox-server-image.yml | 14 +- .github/workflows/docs-deploy.yml | 10 +- .github/workflows/docs-preview.yml | 10 +- .github/workflows/fastgpt-build-image.yml | 30 +- .github/workflows/fastgpt-preview-image.yml | 10 +- .github/workflows/marketplace-image.yml | 14 +- .github/workflows/mcp_server-build-image.yml | 18 +- .github/workflows/sandbox-build-image.yml | 18 +- packages/global/support/wallet/sub/type.ts | 33 +- .../service/support/permission/teamLimit.ts | 20 +- packages/service/support/wallet/sub/utils.ts | 178 ++-- .../src/components/Select/FileSelectorBox.tsx | 6 +- .../src/components/core/app/FileSelect.tsx | 2 +- .../core/app/FileSelector/index.tsx | 4 +- .../ChatBox/hooks/useFileUpload.tsx | 4 +- .../wallet/StandardPlanContentList.tsx | 12 +- .../nodes/NodePluginIO/InputTypeConfig.tsx | 2 +- .../dashboard/TeamPlanStatusCard.tsx | 2 +- .../detail/Import/components/FileSelector.tsx | 4 +- .../app/src/pageComponents/price/Standard.tsx | 15 +- .../src/pages/account/customDomain/index.tsx | 2 +- projects/app/src/pages/account/info/index.tsx | 35 +- .../admin/support/appRegistration/create.ts | 2 +- .../api/common/file/presignTempFilePostUrl.ts | 6 +- .../core/chat/file/presignChatFilePostUrl.ts | 6 +- .../core/dataset/collection/create/images.ts | 3 +- .../api/core/dataset/data/insertImages.ts | 3 +- .../core/dataset/presignDatasetFilePostUrl.ts | 6 +- .../support/permission/teamLimit.test.ts | 780 ++++++++++++++ .../service/support/wallet/sub/utils.test.ts | 988 ++++++++++++++++++ 30 files changed, 1992 insertions(+), 245 deletions(-) create mode 100644 test/cases/service/support/permission/teamLimit.test.ts create mode 100644 test/cases/service/support/wallet/sub/utils.test.ts diff --git a/.github/workflows/build-sandbox-server-image.yml b/.github/workflows/build-sandbox-server-image.yml index cbee8a9e7b..9387a0904e 100644 --- a/.github/workflows/build-sandbox-server-image.yml +++ b/.github/workflows/build-sandbox-server-image.yml @@ -52,8 +52,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Build for ${{ matrix.archs.arch }} id: build @@ -65,7 +65,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.description=FastGPT Sandbox Server image - outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server,${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server",push-by-digest=true,push=true + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server,${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox-server",push-by-digest=true,push=true cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache @@ -103,8 +103,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Download digests uses: actions/download-artifact@v4 @@ -122,8 +122,8 @@ jobs: TAGS=( "ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server:${{ inputs.tag }}" "ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox-server:latest" - "${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server:${{ inputs.tag }}" - "${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox-server:latest" + "${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox-server:${{ inputs.tag }}" + "${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox-server:latest" ) for TAG in "${TAGS[@]}"; do docker buildx imagetools create -t $TAG \ diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 95faa61979..f335394000 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -97,7 +97,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - ${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs + ${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs tags: | ${{ matrix.domain_config.suffix }}-${{ needs.generate-timestamp.outputs.datetime }} flavor: latest=false @@ -106,8 +106,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Build and push Docker images (CN) if: matrix.domain_config.suffix == 'cn' @@ -166,8 +166,8 @@ jobs: - name: Update deployment image run: | - kubectl set image deployment/${{ matrix.domain_config.deployment }} ${{ matrix.domain_config.deployment }}=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs:${{ matrix.domain_config.suffix }}-${{ needs.generate-timestamp.outputs.datetime }} + kubectl set image deployment/${{ matrix.domain_config.deployment }} ${{ matrix.domain_config.deployment }}=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs:${{ matrix.domain_config.suffix }}-${{ needs.generate-timestamp.outputs.datetime }} - name: Annotate deployment run: | - kubectl annotate deployment/${{ matrix.domain_config.deployment }} originImageName="${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs:${{ matrix.domain_config.suffix }}-${{ needs.generate-timestamp.outputs.datetime }}" --overwrite + kubectl annotate deployment/${{ matrix.domain_config.deployment }} originImageName="${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs:${{ matrix.domain_config.suffix }}-${{ needs.generate-timestamp.outputs.datetime }}" --overwrite diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 367b9f7fac..7227777793 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -32,7 +32,7 @@ jobs: with: # list of Docker images to use as base name for tags images: | - ${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs + ${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs tags: | ${{ steps.datetime.outputs.datetime }} flavor: latest=false @@ -41,8 +41,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -75,11 +75,11 @@ jobs: - name: Update deployment image run: | - kubectl set image deployment/fastgpt-docs-preview fastgpt-docs-preview=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs:${{ needs.build-images.outputs.tags }} + kubectl set image deployment/fastgpt-docs-preview fastgpt-docs-preview=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs:${{ needs.build-images.outputs.tags }} - name: Annotate deployment run: | - kubectl annotate deployment/fastgpt-docs-preview originImageName="${{ secrets.ALI_IMAGE_NAME }}/fastgpt-docs:${{ needs.build-images.outputs.tags }}" --overwrite + kubectl annotate deployment/fastgpt-docs-preview originImageName="${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-docs:${{ needs.build-images.outputs.tags }}" --overwrite - name: '@finleyge/github-tools' uses: FinleyGe/github-tools@0.0.1 diff --git a/.github/workflows/fastgpt-build-image.yml b/.github/workflows/fastgpt-build-image.yml index fe6226ab33..1f81f438a2 100644 --- a/.github/workflows/fastgpt-build-image.yml +++ b/.github/workflows/fastgpt-build-image.yml @@ -4,10 +4,10 @@ on: workflow_dispatch: push: paths: - - "projects/app/**" - - "packages/**" + - 'projects/app/**' + - 'packages/**' tags: - - "v*" + - 'v*' jobs: build-fastgpt-images: @@ -20,11 +20,11 @@ jobs: matrix: sub_routes: - repo: fastgpt - base_url: "" + base_url: '' - repo: fastgpt-sub-route - base_url: "/fastai" + base_url: '/fastai' - repo: fastgpt-sub-route-gchat - base_url: "/gchat" + base_url: '/gchat' archs: - arch: amd64 - arch: arm64 @@ -61,8 +61,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -81,7 +81,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.description=${{ matrix.sub_routes.repo }} image - outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }},${{ secrets.ALI_IMAGE_NAME }}/${{ matrix.sub_routes.repo }},${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}",push-by-digest=true,push=true + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }},${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/${{ matrix.sub_routes.repo }},${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}",push-by-digest=true,push=true cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache @@ -124,8 +124,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -147,15 +147,15 @@ jobs: if [[ "${{ github.ref_name }}" == "main" ]]; then echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV else echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }}:${{ github.ref_name }}" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:${{ github.ref_name }}" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/${{ matrix.sub_routes.repo }}:${{ github.ref_name }}" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:${{ github.ref_name }}" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/${{ matrix.sub_routes.repo }}:latest" >> $GITHUB_ENV fi diff --git a/.github/workflows/fastgpt-preview-image.yml b/.github/workflows/fastgpt-preview-image.yml index 7e1591b8b7..9c4795241b 100644 --- a/.github/workflows/fastgpt-preview-image.yml +++ b/.github/workflows/fastgpt-preview-image.yml @@ -45,8 +45,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Set image config id: config @@ -54,15 +54,15 @@ jobs: if [[ "${{ matrix.image }}" == "fastgpt" ]]; then echo "DOCKERFILE=projects/app/Dockerfile" >> $GITHUB_OUTPUT echo "DESCRIPTION=fastgpt-pr image" >> $GITHUB_OUTPUT - echo "DOCKER_REPO_TAGGED=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-pr:fatsgpt_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT + echo "DOCKER_REPO_TAGGED=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-pr:fatsgpt_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT elif [[ "${{ matrix.image }}" == "sandbox" ]]; then echo "DOCKERFILE=projects/sandbox/Dockerfile" >> $GITHUB_OUTPUT echo "DESCRIPTION=fastgpt-sandbox-pr image" >> $GITHUB_OUTPUT - echo "DOCKER_REPO_TAGGED=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-pr:fatsgpt_sandbox_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT + echo "DOCKER_REPO_TAGGED=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-pr:fatsgpt_sandbox_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT elif [[ "${{ matrix.image }}" == "mcp_server" ]]; then echo "DOCKERFILE=projects/mcp_server/Dockerfile" >> $GITHUB_OUTPUT echo "DESCRIPTION=fastgpt-mcp_server-pr image" >> $GITHUB_OUTPUT - echo "DOCKER_REPO_TAGGED=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-pr:fatsgpt_mcp_server_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT + echo "DOCKER_REPO_TAGGED=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-pr:fatsgpt_mcp_server_${{ github.event.pull_request.head.sha }}" >> $GITHUB_OUTPUT fi - name: Build ${{ matrix.image }} image for PR diff --git a/.github/workflows/marketplace-image.yml b/.github/workflows/marketplace-image.yml index 1c4f1d38f3..78dd9bff9e 100644 --- a/.github/workflows/marketplace-image.yml +++ b/.github/workflows/marketplace-image.yml @@ -44,8 +44,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Build for ${{ matrix.arch }} id: build @@ -57,7 +57,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.description=fastgpt-marketplace image - outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-marketplace,${{ secrets.ALI_IMAGE_NAME }}/fastgpt-marketplace",push-by-digest=true,push=true + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-marketplace,${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-marketplace",push-by-digest=true,push=true cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache @@ -94,8 +94,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Download digests uses: actions/download-artifact@v4 @@ -118,7 +118,7 @@ jobs: - name: Set image name and tag run: | echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/fastgpt-marketplace:${{ env.RANDOM_TAG }}" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-marketplace:${{ env.RANDOM_TAG }}" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-marketplace:${{ env.RANDOM_TAG }}" >> $GITHUB_ENV - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests @@ -138,7 +138,7 @@ jobs: # Create manifest for Ali Cloud echo "Creating manifest for Ali Cloud: ${Ali_Tag}" docker buildx imagetools create -t ${Ali_Tag} \ - $(printf '${{ secrets.ALI_IMAGE_NAME }}/fastgpt-marketplace@sha256:%s ' *) + $(printf '${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-marketplace@sha256:%s ' *) echo "✅ Ali Cloud manifest created" echo "" diff --git a/.github/workflows/mcp_server-build-image.yml b/.github/workflows/mcp_server-build-image.yml index 83b79531c9..6fd9d8736d 100644 --- a/.github/workflows/mcp_server-build-image.yml +++ b/.github/workflows/mcp_server-build-image.yml @@ -49,8 +49,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -67,7 +67,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.description=fastgpt-mcp_server image - outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server,${{ secrets.ALI_IMAGE_NAME }}/fastgpt-mcp_server,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server",push-by-digest=true,push=true + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server,${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-mcp_server,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server",push-by-digest=true,push=true cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache @@ -104,8 +104,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -127,15 +127,15 @@ jobs: if [[ "${{ github.ref_name }}" == "main" ]]; then echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV else echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server:${{ github.ref_name }}" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-mcp_server:${{ github.ref_name }}" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-mcp_server:${{ github.ref_name }}" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server:${{ github.ref_name }}" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-mcp_server:latest" >> $GITHUB_ENV fi diff --git a/.github/workflows/sandbox-build-image.yml b/.github/workflows/sandbox-build-image.yml index 89727bdfd6..7e6a0d1480 100644 --- a/.github/workflows/sandbox-build-image.yml +++ b/.github/workflows/sandbox-build-image.yml @@ -49,8 +49,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -67,7 +67,7 @@ jobs: labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.description=fastgpt-sandbox image - outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox,${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox",push-by-digest=true,push=true + outputs: type=image,"name=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox,${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox,${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox",push-by-digest=true,push=true cache-from: type=local,src=/tmp/.buildx-cache cache-to: type=local,dest=/tmp/.buildx-cache @@ -104,8 +104,8 @@ jobs: uses: docker/login-action@v3 with: registry: registry.cn-hangzhou.aliyuncs.com - username: ${{ secrets.ALI_HUB_USERNAME }} - password: ${{ secrets.ALI_HUB_PASSWORD }} + username: ${{ secrets.FASTGPT_ALI_IMAGE_USER }} + password: ${{ secrets.FASTGPT_ALI_IMAGE_PSW }} - name: Login to Docker Hub uses: docker/login-action@v3 with: @@ -127,15 +127,15 @@ jobs: if [[ "${{ github.ref_name }}" == "main" ]]; then echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox:latest" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox:latest" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV else echo "Git_Tag=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox:${{ github.ref_name }}" >> $GITHUB_ENV echo "Git_Latest=ghcr.io/${{ github.repository_owner }}/fastgpt-sandbox:latest" >> $GITHUB_ENV - echo "Ali_Tag=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox:${{ github.ref_name }}" >> $GITHUB_ENV - echo "Ali_Latest=${{ secrets.ALI_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV + echo "Ali_Tag=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox:${{ github.ref_name }}" >> $GITHUB_ENV + echo "Ali_Latest=${{ secrets.FASTGPT_ALI_IMAGE_PREFIX }}/fastgpt-sandbox:latest" >> $GITHUB_ENV echo "Docker_Hub_Tag=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox:${{ github.ref_name }}" >> $GITHUB_ENV echo "Docker_Hub_Latest=${{ secrets.DOCKER_IMAGE_NAME }}/fastgpt-sandbox:latest" >> $GITHUB_ENV fi diff --git a/packages/global/support/wallet/sub/type.ts b/packages/global/support/wallet/sub/type.ts index bb3efa2f88..ea43cd01b6 100644 --- a/packages/global/support/wallet/sub/type.ts +++ b/packages/global/support/wallet/sub/type.ts @@ -2,7 +2,9 @@ import z from 'zod'; import { StandardSubLevelEnum, SubModeEnum, SubTypeEnum } from './constants'; import { ObjectIdSchema } from '../../../common/type/mongo'; -// Content of plan +/** + * Static plan config, stored in global.subPlans + */ export const TeamStandardSubPlanItemSchema = z.object({ name: z.string().optional(), desc: z.string().optional(), @@ -71,6 +73,10 @@ export const SubPlanSchema = z.object({ }); export type SubPlanType = z.infer; +/** + * TeamSub Schema in DB. + * Configs are optional + */ export const TeamSubSchema = z.object({ _id: ObjectIdSchema, teamId: ObjectIdSchema, @@ -105,9 +111,30 @@ export const TeamSubSchema = z.object({ }); export type TeamSubSchemaType = z.infer; +/** + * Merged plan type: combines DB subscription record metadata with effective plan limits + * + * Omits: + * - maxApp/maxDataset from TeamSubSchema: 这些字段在 DB 中存储,但在合并后的类型中使用 maxAppAmount/maxDatasetAmount + * - pointPrice from TeamStandardSubPlanItemSchema: 避免与 price 字段冲突 + * + * Field priority: TeamStandardSubPlanItemSchema fields override TeamSubSchema fields when both exist + */ export const TeamPlanStandardSchema = z.object({ + ...TeamSubSchema.omit({ + maxApp: true, + maxDataset: true + }).shape, + ...TeamStandardSubPlanItemSchema.omit({ + pointPrice: true, + price: true + }).shape, + price: z.number().optional() +}); + +export type TeamPlanStandardType = z.infer; + export const TeamPlanStatusSchema = z.object({ - [SubTypeEnum.standard]: TeamSubSchema.optional(), - standardConstants: TeamStandardSubPlanItemSchema.optional(), + [SubTypeEnum.standard]: TeamPlanStandardSchema.optional(), totalPoints: z.int(), usedPoints: z.int(), datasetMaxSize: z.int() diff --git a/packages/service/support/permission/teamLimit.ts b/packages/service/support/permission/teamLimit.ts index 207c458012..68628302b0 100644 --- a/packages/service/support/permission/teamLimit.ts +++ b/packages/service/support/permission/teamLimit.ts @@ -25,7 +25,7 @@ export const checkTeamAIPoints = async (teamId: string) => { }; export const checkTeamMemberLimit = async (teamId: string, newCount: number) => { - const [{ standardConstants }, memberCount] = await Promise.all([ + const [{ standard }, memberCount] = await Promise.all([ getTeamStandPlan({ teamId }), @@ -35,7 +35,7 @@ export const checkTeamMemberLimit = async (teamId: string, newCount: number) => }) ]); - if (standardConstants && newCount + memberCount > standardConstants.maxTeamMember) { + if (standard?.maxTeamMember && newCount + memberCount > standard.maxTeamMember) { return Promise.reject(TeamErrEnum.teamOverSize); } }; @@ -50,7 +50,7 @@ export const checkTeamAppTypeLimit = async ({ amount?: number; }) => { if (appCheckType === 'app') { - const [{ standardConstants }, appCount] = await Promise.all([ + const [{ standard }, appCount] = await Promise.all([ getTeamStandPlan({ teamId }), MongoApp.countDocuments({ teamId, @@ -60,7 +60,7 @@ export const checkTeamAppTypeLimit = async ({ }) ]); - if (standardConstants && appCount + amount > standardConstants.maxAppAmount) { + if (standard?.maxAppAmount && appCount + amount > standard.maxAppAmount) { return Promise.reject(TeamErrEnum.appAmountNotEnough); } @@ -107,10 +107,10 @@ export const checkDatasetIndexLimit = async ({ teamId: string; insertLen?: number; }) => { - const [{ standardConstants, totalPoints, usedPoints, datasetMaxSize }, usedDatasetIndexSize] = + const [{ standard, totalPoints, usedPoints, datasetMaxSize }, usedDatasetIndexSize] = await Promise.all([getTeamPlanStatus({ teamId }), getVectorCountByTeamId(teamId)]); - if (!standardConstants) return; + if (!standard) return; if (usedDatasetIndexSize + insertLen >= datasetMaxSize) { return Promise.reject(TeamErrEnum.datasetSizeNotEnough); @@ -123,7 +123,7 @@ export const checkDatasetIndexLimit = async ({ }; export const checkTeamDatasetLimit = async (teamId: string) => { - const [{ standardConstants }, datasetCount] = await Promise.all([ + const [{ standard }, datasetCount] = await Promise.all([ getTeamStandPlan({ teamId }), MongoDataset.countDocuments({ teamId, @@ -132,7 +132,7 @@ export const checkTeamDatasetLimit = async (teamId: string) => { ]); // User check - if (standardConstants && datasetCount >= standardConstants.maxDatasetAmount) { + if (standard?.maxDatasetAmount && datasetCount >= standard.maxDatasetAmount) { return Promise.reject(TeamErrEnum.datasetAmountNotEnough); } @@ -148,11 +148,11 @@ export const checkTeamDatasetLimit = async (teamId: string) => { }; export const checkTeamDatasetSyncPermission = async (teamId: string) => { - const { standardConstants } = await getTeamStandPlan({ + const { standard } = await getTeamStandPlan({ teamId }); - if (standardConstants && !standardConstants?.websiteSyncPerDataset) { + if (standard && !standard?.websiteSyncPerDataset) { return Promise.reject(TeamErrEnum.websiteSyncNotEnough); } }; diff --git a/packages/service/support/wallet/sub/utils.ts b/packages/service/support/wallet/sub/utils.ts index d0a76addf3..360fd8f6b6 100644 --- a/packages/service/support/wallet/sub/utils.ts +++ b/packages/service/support/wallet/sub/utils.ts @@ -5,9 +5,11 @@ import { standardSubLevelMap } from '@fastgpt/global/support/wallet/sub/constants'; import { MongoTeamSub } from './schema'; -import { - type TeamPlanStatusType, - type TeamSubSchemaType +import type { + TeamStandardSubPlanItemType, + TeamPlanStatusType, + TeamPlanStandardType, + TeamSubSchemaType } from '@fastgpt/global/support/wallet/sub/type'; import dayjs from 'dayjs'; import { type ClientSession } from '../../../common/mongo'; @@ -38,55 +40,33 @@ export const sortStandPlans = (plans: TeamSubSchemaType[]) => { standardSubLevelMap[b.currentSubLevel].weight - standardSubLevelMap[a.currentSubLevel].weight ); }; -export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { - const plans = await MongoTeamSub.find( - { - teamId, - type: SubTypeEnum.standard - }, - undefined, - { - ...readFromSecondary - } - ); - sortStandPlans(plans); - - const standardPlans = global.subPlans?.standard; - const standard = plans[0]; - - const standardConstants = - standard.currentSubLevel && standardPlans - ? standardPlans[ - standard.currentSubLevel === StandardSubLevelEnum.custom - ? StandardSubLevelEnum.advanced - : standard.currentSubLevel - ] - : undefined; - - return { - [SubTypeEnum.standard]: standard, - standardConstants: standardConstants - ? { - ...standardConstants, - maxTeamMember: standard?.maxTeamMember ?? standardConstants.maxTeamMember, - maxAppAmount: standard?.maxApp ?? standardConstants.maxAppAmount, - maxDatasetAmount: standard?.maxDataset ?? standardConstants.maxDatasetAmount, - requestsPerMinute: standard?.requestsPerMinute ?? standardConstants.requestsPerMinute, - chatHistoryStoreDuration: - standard?.chatHistoryStoreDuration ?? standardConstants.chatHistoryStoreDuration, - maxDatasetSize: standard?.maxDatasetSize ?? standardConstants.maxDatasetSize, - websiteSyncPerDataset: - standard?.websiteSyncPerDataset ?? standardConstants.websiteSyncPerDataset, - appRegistrationCount: - standard?.appRegistrationCount ?? standardConstants.appRegistrationCount, - auditLogStoreDuration: - standard?.auditLogStoreDuration ?? standardConstants.auditLogStoreDuration, - ticketResponseTime: standard?.ticketResponseTime ?? standardConstants.ticketResponseTime, - customDomain: standard?.customDomain ?? standardConstants.customDomain - } - : undefined - }; -}; +export const buildStandardPlan = ( + standard: TeamSubSchemaType, + standardConstants: TeamStandardSubPlanItemType +): TeamPlanStandardType => ({ + ...standard, + name: standardConstants.name, + desc: standardConstants.desc, + price: standardConstants.price, + priceDescription: standardConstants.priceDescription, + customFormUrl: standardConstants.customFormUrl, + customDescriptions: standardConstants.customDescriptions, + wecom: standardConstants.wecom, + maxTeamMember: standard?.maxTeamMember ?? standardConstants.maxTeamMember, + maxAppAmount: standard?.maxApp ?? standardConstants.maxAppAmount, + maxDatasetAmount: standard?.maxDataset ?? standardConstants.maxDatasetAmount, + requestsPerMinute: standard?.requestsPerMinute ?? standardConstants.requestsPerMinute, + chatHistoryStoreDuration: + standard?.chatHistoryStoreDuration ?? standardConstants.chatHistoryStoreDuration, + maxDatasetSize: standard?.maxDatasetSize ?? standardConstants.maxDatasetSize, + websiteSyncPerDataset: standard?.websiteSyncPerDataset ?? standardConstants.websiteSyncPerDataset, + appRegistrationCount: standard?.appRegistrationCount ?? standardConstants.appRegistrationCount, + auditLogStoreDuration: standard?.auditLogStoreDuration ?? standardConstants.auditLogStoreDuration, + ticketResponseTime: standard?.ticketResponseTime ?? standardConstants.ticketResponseTime, + customDomain: standard?.customDomain ?? standardConstants.customDomain, + maxUploadFileSize: standard?.maxUploadFileSize ?? standardConstants.maxUploadFileSize, + maxUploadFileCount: standard?.maxUploadFileCount ?? standardConstants.maxUploadFileCount +}); export const initTeamFreePlan = async ({ teamId, @@ -176,11 +156,46 @@ export const initTeamFreePlan = async ({ ); }; +// 获取团队标准套餐 +export const getTeamStandPlan = async ({ teamId }: { teamId: string }) => { + const plans = await MongoTeamSub.find( + { + teamId, + type: SubTypeEnum.standard + }, + undefined, + { + ...readFromSecondary + } + ); + sortStandPlans(plans); + + const standardPlans = global.subPlans?.standard; + const standard = plans[0]; + + const standardConstants = + standard.currentSubLevel && standardPlans + ? standardPlans[ + standard.currentSubLevel === StandardSubLevelEnum.custom + ? StandardSubLevelEnum.advanced + : standard.currentSubLevel + ] + : undefined; + + return { + [SubTypeEnum.standard]: standardConstants + ? buildStandardPlan(standard, standardConstants) + : undefined + }; +}; + +// 获取团队所有套餐内容 export const getTeamPlanStatus = async ({ teamId }: { teamId: string; }): Promise => { + /** 配置里的套餐 */ const standardPlans = global.subPlans?.standard; /* Get all plans and datasetSize */ @@ -190,6 +205,7 @@ export const getTeamPlanStatus = async ({ const teamStandardPlans = sortStandPlans( plans.filter((plan) => plan.type === SubTypeEnum.standard) ); + /** 数据库里的,用户目前 active 的套餐 */ const standardPlan = teamStandardPlans[0]; const extraDatasetSize = plans.filter((plan) => plan.type === SubTypeEnum.extraDatasetSize); @@ -230,6 +246,7 @@ export const getTeamPlanStatus = async ({ standardMaxDatasetSize + extraDatasetSize.reduce((acc, cur) => acc + (cur.currentExtraDatasetSize || 0), 0); + /** 静态的套餐配置,如果是 custom 则返回 advanced */ const standardConstants = standardPlan?.currentSubLevel && standardPlans ? standardPlans[ @@ -242,56 +259,8 @@ export const getTeamPlanStatus = async ({ teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints }); return { - standard: - standardPlan.currentSubLevel === StandardSubLevelEnum.custom && standardConstants - ? { - ...standardPlan, - maxTeamMember: standardPlan?.maxTeamMember ?? standardConstants.maxTeamMember, - maxApp: standardPlan?.maxApp ?? standardConstants.maxAppAmount, - maxDataset: standardPlan?.maxDataset ?? standardConstants.maxDatasetAmount, - requestsPerMinute: - standardPlan?.requestsPerMinute ?? standardConstants.requestsPerMinute, - chatHistoryStoreDuration: - standardPlan?.chatHistoryStoreDuration ?? standardConstants.chatHistoryStoreDuration, - maxDatasetSize: standardPlan?.maxDatasetSize ?? standardConstants.maxDatasetSize, - websiteSyncPerDataset: - standardPlan?.websiteSyncPerDataset || standardConstants.websiteSyncPerDataset, - appRegistrationCount: - standardPlan?.appRegistrationCount ?? standardConstants.appRegistrationCount, - auditLogStoreDuration: - standardPlan?.auditLogStoreDuration ?? standardConstants.auditLogStoreDuration, - ticketResponseTime: - standardPlan?.ticketResponseTime ?? standardConstants.ticketResponseTime, - customDomain: standardPlan?.customDomain ?? standardConstants.customDomain, - maxUploadFileSize: - standardPlan?.maxUploadFileSize ?? standardConstants.maxUploadFileSize, - maxUploadFileCount: - standardPlan?.maxUploadFileCount ?? standardConstants.maxUploadFileCount - } - : standardPlan, - standardConstants: standardConstants - ? { - ...standardConstants, - maxTeamMember: standardPlan?.maxTeamMember ?? standardConstants.maxTeamMember, - maxAppAmount: standardPlan?.maxApp ?? standardConstants.maxAppAmount, - maxDatasetAmount: standardPlan?.maxDataset ?? standardConstants.maxDatasetAmount, - requestsPerMinute: standardPlan?.requestsPerMinute ?? standardConstants.requestsPerMinute, - chatHistoryStoreDuration: - standardPlan?.chatHistoryStoreDuration ?? standardConstants.chatHistoryStoreDuration, - maxDatasetSize: standardPlan?.maxDatasetSize ?? standardConstants.maxDatasetSize, - websiteSyncPerDataset: - standardPlan?.websiteSyncPerDataset || standardConstants.websiteSyncPerDataset, - appRegistrationCount: - standardPlan?.appRegistrationCount ?? standardConstants.appRegistrationCount, - auditLogStoreDuration: - standardPlan?.auditLogStoreDuration ?? standardConstants.auditLogStoreDuration, - ticketResponseTime: - standardPlan?.ticketResponseTime ?? standardConstants.ticketResponseTime, - customDomain: standardPlan?.customDomain ?? standardConstants.customDomain, - maxUploadFileSize: standardPlan?.maxUploadFileSize ?? standardConstants.maxUploadFileSize, - maxUploadFileCount: - standardPlan?.maxUploadFileCount ?? standardConstants.maxUploadFileCount - } + [SubTypeEnum.standard]: standardConstants + ? buildStandardPlan(standardPlan, standardConstants) : undefined, totalPoints, @@ -301,6 +270,7 @@ export const getTeamPlanStatus = async ({ }; }; +/* ===== Buffer controller ===== */ export const teamPoint = { getTeamPoints: async ({ teamId }: { teamId: string }) => { const surplusCacheKey = `${CacheKeyEnum.team_point_surplus}:${teamId}`; @@ -368,9 +338,7 @@ export const teamQPM = { // 2. Computed const teamPlanStatus = await getTeamPlanStatus({ teamId }); - const limit = - teamPlanStatus[SubTypeEnum.standard]?.requestsPerMinute ?? - teamPlanStatus.standardConstants?.requestsPerMinute; + const limit = teamPlanStatus[SubTypeEnum.standard]?.requestsPerMinute; if (!limit) { if (process.env.CHAT_MAX_QPM) return Number(process.env.CHAT_MAX_QPM); diff --git a/projects/app/src/components/Select/FileSelectorBox.tsx b/projects/app/src/components/Select/FileSelectorBox.tsx index 30991496bd..0f3d940c5b 100644 --- a/projects/app/src/components/Select/FileSelectorBox.tsx +++ b/projects/app/src/components/Select/FileSelectorBox.tsx @@ -39,14 +39,12 @@ const FileSelector = ({ // 文件大小限制(B):团队套餐 || 系统配置 || 默认值 const displayMaxSize = formatFileSize( - (teamPlanStatus?.standardConstants?.maxUploadFileSize || feConfigs.uploadFileMaxSize) * - 1024 * - 1024 + (teamPlanStatus?.standard?.maxUploadFileSize || feConfigs.uploadFileMaxSize) * 1024 * 1024 ); // 文件数量限制:组件传入的maxCount || 团队套餐 || 系统配置 const formatMaxCount = Math.min( maxCount, - teamPlanStatus?.standardConstants?.maxUploadFileCount || feConfigs.uploadFileMaxAmount + teamPlanStatus?.standard?.maxUploadFileCount || feConfigs.uploadFileMaxAmount ); const { File, onOpen } = useSelectFile({ diff --git a/projects/app/src/components/core/app/FileSelect.tsx b/projects/app/src/components/core/app/FileSelect.tsx index bd9a5c4a61..95fbd45749 100644 --- a/projects/app/src/components/core/app/FileSelect.tsx +++ b/projects/app/src/components/core/app/FileSelect.tsx @@ -44,7 +44,7 @@ const FileSelect = ({ // 文件数量限制:团队套餐 || 系统配置 || 默认值(这里是指对话中,最多上传多少文件) const maxSelectFiles = Math.min( - teamPlanStatus?.standardConstants?.maxUploadFileCount || feConfigs.uploadFileMaxAmount, + teamPlanStatus?.standard?.maxUploadFileCount || feConfigs.uploadFileMaxAmount, 50 ); diff --git a/projects/app/src/components/core/app/FileSelector/index.tsx b/projects/app/src/components/core/app/FileSelector/index.tsx index 472475cd73..e56452e59c 100644 --- a/projects/app/src/components/core/app/FileSelector/index.tsx +++ b/projects/app/src/components/core/app/FileSelector/index.tsx @@ -94,12 +94,12 @@ const FileSelector = ({ // 文件数量限制:组件参数 || 团队套餐 || 系统配置 || 默认值 const maxSelectFiles = maxFiles || - teamPlanStatus?.standardConstants?.maxUploadFileCount || + teamPlanStatus?.standard?.maxUploadFileCount || feConfigs?.uploadFileMaxAmount || 10; // 文件大小限制(MB):团队套餐 || 系统配置 || 默认值 const maxSize = - (teamPlanStatus?.standardConstants?.maxUploadFileSize || feConfigs?.uploadFileMaxSize || 500) * + (teamPlanStatus?.standard?.maxUploadFileSize || feConfigs?.uploadFileMaxSize || 500) * 1024 * 1024; const canSelectFileAmount = maxSelectFiles - value.length; diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useFileUpload.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useFileUpload.tsx index c91183e050..b94ed5341e 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useFileUpload.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/hooks/useFileUpload.tsx @@ -57,12 +57,12 @@ export const useFileUpload = (props: UseFileUploadOptions) => { // 文件数量限制:配置的maxFiles || 团队套餐 || 系统配置 || 默认值 const maxSelectFiles = fileSelectConfig?.maxFiles || - teamPlanStatus?.standardConstants?.maxUploadFileCount || + teamPlanStatus?.standard?.maxUploadFileCount || feConfigs?.uploadFileMaxAmount || 10; // 文件大小限制(MB):团队套餐 || 系统配置 || 默认值 const maxSize = - (teamPlanStatus?.standardConstants?.maxUploadFileSize || feConfigs?.uploadFileMaxSize || 500) * + (teamPlanStatus?.standard?.maxUploadFileSize || feConfigs?.uploadFileMaxSize || 500) * 1024 * 1024; const canSelectFileAmount = maxSelectFiles - fileList.length; diff --git a/projects/app/src/components/support/wallet/StandardPlanContentList.tsx b/projects/app/src/components/support/wallet/StandardPlanContentList.tsx index 7f61c43f1a..ed18e7b19f 100644 --- a/projects/app/src/components/support/wallet/StandardPlanContentList.tsx +++ b/projects/app/src/components/support/wallet/StandardPlanContentList.tsx @@ -12,7 +12,7 @@ import Markdown from '@/components/Markdown'; import MyPopover from '@fastgpt/web/components/common/MyPopover'; import { useUserStore } from '@/web/support/user/useUserStore'; import { formatFileSize } from '@fastgpt/global/common/file/tools'; -import type { TeamSubSchemaType } from '@fastgpt/global/support/wallet/sub/type'; +import type { TeamPlanStandardType } from '@fastgpt/global/support/wallet/sub/type'; const ModelPriceModal = dynamic(() => import('@/components/core/ai/ModelTable').then((mod) => mod.ModelPriceModal) @@ -25,7 +25,7 @@ const StandardPlanContentList = ({ }: { level: `${StandardSubLevelEnum}`; mode: `${SubModeEnum}`; - standplan?: TeamSubSchemaType; + standplan?: TeamPlanStandardType; }) => { const { t } = useTranslation(); @@ -58,8 +58,8 @@ const StandardPlanContentList = ({ : plan.totalPoints * (formatMode === SubModeEnum.month ? 1 : 12)), requestsPerMinute: standplan?.requestsPerMinute ?? plan.requestsPerMinute, maxTeamMember: standplan?.maxTeamMember ?? plan.maxTeamMember, - maxAppAmount: standplan?.maxApp ?? plan.maxAppAmount, - maxDatasetAmount: standplan?.maxDataset ?? plan.maxDatasetAmount, + maxAppAmount: standplan?.maxAppAmount ?? plan.maxAppAmount, + maxDatasetAmount: standplan?.maxDatasetAmount ?? plan.maxDatasetAmount, maxDatasetSize: standplan?.maxDatasetSize ?? plan.maxDatasetSize, websiteSyncPerDataset: standplan?.websiteSyncPerDataset ?? plan.websiteSyncPerDataset, chatHistoryStoreDuration: @@ -84,8 +84,8 @@ const StandardPlanContentList = ({ standplan?.annualBonusPoints, standplan?.requestsPerMinute, standplan?.maxTeamMember, - standplan?.maxApp, - standplan?.maxDataset, + standplan?.maxAppAmount, + standplan?.maxDatasetAmount, standplan?.maxDatasetSize, standplan?.websiteSyncPerDataset, standplan?.chatHistoryStoreDuration, diff --git a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx index 94e80a6c7f..ae5b7b4771 100644 --- a/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx +++ b/projects/app/src/pageComponents/app/detail/WorkflowComponents/Flow/nodes/NodePluginIO/InputTypeConfig.tsx @@ -124,7 +124,7 @@ const InputTypeConfig = ({ const maxFiles = watch('maxFiles') ?? 5; // 文件数量限制:团队套餐 || 系统配置 || 默认值 const maxSelectFiles = Math.min( - teamPlanStatus?.standardConstants?.maxUploadFileCount || feConfigs.uploadFileMaxAmount, + teamPlanStatus?.standard?.maxUploadFileCount || feConfigs.uploadFileMaxAmount, 50 ); const canSelectFile = watch('canSelectFile') ?? true; diff --git a/projects/app/src/pageComponents/dashboard/TeamPlanStatusCard.tsx b/projects/app/src/pageComponents/dashboard/TeamPlanStatusCard.tsx index 1d25c1f9e1..fe03c40acf 100644 --- a/projects/app/src/pageComponents/dashboard/TeamPlanStatusCard.tsx +++ b/projects/app/src/pageComponents/dashboard/TeamPlanStatusCard.tsx @@ -90,7 +90,7 @@ const TeamPlanStatusCard = () => { setHiddenUntil(hideUntilTime); }, [setHiddenUntil, operationalAd]); - if (!teamPlanStatus?.standardConstants) return null; + if (!teamPlanStatus?.standard) return null; return ( 系统配置 > 默认值(500MB) const maxSize = - (teamPlanStatus?.standardConstants?.maxUploadFileSize ?? feConfigs?.uploadFileMaxSize ?? 500) * + (teamPlanStatus?.standard?.maxUploadFileSize ?? feConfigs?.uploadFileMaxSize ?? 500) * 1024 * 1024; diff --git a/projects/app/src/pageComponents/price/Standard.tsx b/projects/app/src/pageComponents/price/Standard.tsx index 336d70d0ab..1913cbd7b0 100644 --- a/projects/app/src/pageComponents/price/Standard.tsx +++ b/projects/app/src/pageComponents/price/Standard.tsx @@ -6,7 +6,8 @@ import { StandardSubLevelEnum, SubModeEnum } from '@fastgpt/global/support/walle import { useSystemStore } from '@/web/common/system/useSystemStore'; import { standardSubLevelMap } from '@fastgpt/global/support/wallet/sub/constants'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; -import { type TeamSubSchemaType } from '@fastgpt/global/support/wallet/sub/type'; +import type { TeamPlanStandardType } from '@fastgpt/global/support/wallet/sub/type'; + import QRCodePayModal, { type QRPayProps } from '@/components/support/wallet/QRCodePayModal'; import { postCreatePayBill } from '@/web/support/wallet/bill/api'; import { getDiscountCouponList } from '@/web/support/wallet/sub/discountCoupon/api'; @@ -35,7 +36,7 @@ const Standard = ({ standardPlan: myStandardPlan, onPaySuccess }: { - standardPlan?: TeamSubSchemaType; + standardPlan?: TeamPlanStandardType; onPaySuccess?: () => void; }) => { const { t } = useTranslation(); @@ -112,9 +113,9 @@ const Standard = ({ ? value.wecom.price : value.price * (selectSubMode === SubModeEnum.month ? 1 : 10), level: level as `${StandardSubLevelEnum}`, - maxTeamMember: myStandardPlan?.maxTeamMember || value.maxTeamMember, - maxAppAmount: myStandardPlan?.maxApp || value.maxAppAmount, - maxDatasetAmount: myStandardPlan?.maxDataset || value.maxDatasetAmount, + maxTeamMember: myStandardPlan?.maxTeamMember ?? value.maxTeamMember, + maxAppAmount: myStandardPlan?.maxAppAmount ?? value.maxAppAmount, + maxDatasetAmount: myStandardPlan?.maxDatasetAmount ?? value.maxDatasetAmount, chatHistoryStoreDuration: value.chatHistoryStoreDuration, maxDatasetSize: value.maxDatasetSize, annualBonusPoints: selectSubMode === SubModeEnum.month ? 0 : value.annualBonusPoints, @@ -135,8 +136,8 @@ const Standard = ({ isWecomTeam, selectSubMode, myStandardPlan?.maxTeamMember, - myStandardPlan?.maxApp, - myStandardPlan?.maxDataset + myStandardPlan?.maxAppAmount, + myStandardPlan?.maxDatasetAmount ]); // Pay code diff --git a/projects/app/src/pages/account/customDomain/index.tsx b/projects/app/src/pages/account/customDomain/index.tsx index c7b074ee61..11e27131de 100644 --- a/projects/app/src/pages/account/customDomain/index.tsx +++ b/projects/app/src/pages/account/customDomain/index.tsx @@ -91,7 +91,7 @@ const CustomDomain = () => { {t('account:custom_domain')} {customDomainList?.length ? ( - `: (${customDomainList.length}/${teamPlanStatus?.standardConstants?.customDomain})` + `: (${customDomainList.length}/${teamPlanStatus?.standard?.customDomain})` ) : ( <> )} diff --git a/projects/app/src/pages/account/info/index.tsx b/projects/app/src/pages/account/info/index.tsx index 6c79b0d755..a9e10fd20d 100644 --- a/projects/app/src/pages/account/info/index.tsx +++ b/projects/app/src/pages/account/info/index.tsx @@ -70,7 +70,7 @@ const ModelPriceModal = dynamic(() => const Info = () => { const { isPc } = useSystem(); const { teamPlanStatus, initUserInfo } = useUserStore(); - const standardPlan = teamPlanStatus?.standardConstants; + const standardPlan = teamPlanStatus?.standard; const { isOpen: isOpenContact, onClose: onCloseContact, onOpen: onOpenContact } = useDisclosure(); useMount(() => { @@ -125,7 +125,7 @@ const MyInfo = ({ onOpenContact }: { onOpenContact: () => void }) => { const { reset } = useForm({ defaultValues: userInfo as UserType }); - const standardPlan = teamPlanStatus?.standardConstants; + const standardPlan = teamPlanStatus?.standard; const { isPc } = useSystem(); const { toast } = useToast(); @@ -389,11 +389,10 @@ const PlanUsage = () => { const standardPlan = teamPlanStatus?.standard; const isFreeTeam = useMemo(() => { - if (!teamPlanStatus || !teamPlanStatus?.standardConstants) return false; + if (!teamPlanStatus || !teamPlanStatus?.standard) return false; const hasExtraDatasetSize = - teamPlanStatus.datasetMaxSize > teamPlanStatus.standardConstants.maxDatasetSize; - const hasExtraPoints = - teamPlanStatus.totalPoints > teamPlanStatus.standardConstants.totalPoints; + teamPlanStatus.datasetMaxSize > teamPlanStatus.standard.maxDatasetSize; + const hasExtraPoints = teamPlanStatus.totalPoints > teamPlanStatus.standard.totalPoints; if ( teamPlanStatus?.standard?.currentSubLevel === StandardSubLevelEnum.free && !hasExtraDatasetSize && @@ -449,38 +448,32 @@ const PlanUsage = () => { { label: t('account_info:member_amount'), value: teamPlanStatus.usedMember, - max: teamPlanStatus?.standardConstants?.maxTeamMember ?? t('account_info:unlimited'), - rate: - (teamPlanStatus.usedMember / (teamPlanStatus?.standardConstants?.maxTeamMember || 1)) * - 100 + max: teamPlanStatus?.standard?.maxTeamMember ?? t('account_info:unlimited'), + rate: (teamPlanStatus.usedMember / (teamPlanStatus?.standard?.maxTeamMember || 1)) * 100 }, { label: t('account_info:app_amount'), value: teamPlanStatus.usedAppAmount, - max: teamPlanStatus?.standardConstants?.maxAppAmount ?? t('account_info:unlimited'), - rate: - (teamPlanStatus.usedAppAmount / (teamPlanStatus?.standardConstants?.maxAppAmount || 1)) * - 100 + max: teamPlanStatus?.standard?.maxAppAmount ?? t('account_info:unlimited'), + rate: (teamPlanStatus.usedAppAmount / (teamPlanStatus?.standard?.maxAppAmount || 1)) * 100 }, { label: t('account_info:dataset_amount'), value: teamPlanStatus.usedDatasetSize, - max: teamPlanStatus?.standardConstants?.maxDatasetAmount ?? t('account_info:unlimited'), + max: teamPlanStatus?.standard?.maxDatasetAmount ?? t('account_info:unlimited'), rate: - (teamPlanStatus.usedDatasetSize / - (teamPlanStatus?.standardConstants?.maxDatasetAmount || 1)) * - 100 + (teamPlanStatus.usedDatasetSize / (teamPlanStatus?.standard?.maxDatasetAmount || 1)) * 100 } ]; - if (teamPlanStatus?.standardConstants?.appRegistrationCount) { + if (teamPlanStatus?.standard?.appRegistrationCount) { data.push({ label: t('account_info:app_registration_count'), value: teamPlanStatus.usedRegistrationCount || 0, - max: teamPlanStatus.standardConstants.appRegistrationCount, + max: teamPlanStatus.standard.appRegistrationCount, rate: ((teamPlanStatus.usedRegistrationCount || 0) / - teamPlanStatus.standardConstants.appRegistrationCount) * + teamPlanStatus.standard.appRegistrationCount) * 100 }); } diff --git a/projects/app/src/pages/api/admin/support/appRegistration/create.ts b/projects/app/src/pages/api/admin/support/appRegistration/create.ts index 2e7692926e..f6add5bbc6 100644 --- a/projects/app/src/pages/api/admin/support/appRegistration/create.ts +++ b/projects/app/src/pages/api/admin/support/appRegistration/create.ts @@ -60,7 +60,7 @@ async function handler( // 获取团队套餐信息 const teamPlanStatus = await getTeamPlanStatus({ teamId: String(app.teamId) }); - const appRegistrationLimit = teamPlanStatus?.standardConstants?.appRegistrationCount; + const appRegistrationLimit = teamPlanStatus?.standard?.appRegistrationCount; // 检查是否有配额限制 if (appRegistrationLimit && appRegistrationLimit > 0) { diff --git a/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts b/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts index 113d2d1cc1..824251a164 100644 --- a/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts +++ b/projects/app/src/pages/api/common/file/presignTempFilePostUrl.ts @@ -28,8 +28,7 @@ async function handler( await authFrequencyLimit({ eventId: `${tmbId}-uploadfile`, - maxAmount: - planStatus.standardConstants?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, + maxAmount: planStatus.standard?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, expiredTime: addSeconds(new Date(), 30) // 30s }); @@ -40,8 +39,7 @@ async function handler( { rawKey: fileKey, filename }, { expiredHours: 1, - maxFileSize: - planStatus.standardConstants?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize + maxFileSize: planStatus.standard?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize } ); } diff --git a/projects/app/src/pages/api/core/chat/file/presignChatFilePostUrl.ts b/projects/app/src/pages/api/core/chat/file/presignChatFilePostUrl.ts index 2071ecf571..fd0920db3f 100644 --- a/projects/app/src/pages/api/core/chat/file/presignChatFilePostUrl.ts +++ b/projects/app/src/pages/api/core/chat/file/presignChatFilePostUrl.ts @@ -24,8 +24,7 @@ async function handler( const planStatus = await getTeamPlanStatus({ teamId }); await authFrequencyLimit({ eventId: `${uid}-uploadfile`, - maxAmount: - planStatus.standardConstants?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, + maxAmount: planStatus.standard?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, expiredTime: addSeconds(new Date(), 30) // 30s }); @@ -34,8 +33,7 @@ async function handler( chatId, filename, uId: uid, - maxFileSize: - planStatus.standardConstants?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize + maxFileSize: planStatus.standard?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize }); } diff --git a/projects/app/src/pages/api/core/dataset/collection/create/images.ts b/projects/app/src/pages/api/core/dataset/collection/create/images.ts index 290d34ea76..011a431a86 100644 --- a/projects/app/src/pages/api/core/dataset/collection/create/images.ts +++ b/projects/app/src/pages/api/core/dataset/collection/create/images.ts @@ -42,8 +42,7 @@ async function handler( const planStatus = await getTeamPlanStatus({ teamId }); await authFrequencyLimit({ eventId: `${tmbId}-uploadfile`, - maxAmount: - planStatus.standardConstants?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, + maxAmount: planStatus.standard?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, expiredTime: addSeconds(new Date(), 30), // 30s num: result.fileMetadata.length }); diff --git a/projects/app/src/pages/api/core/dataset/data/insertImages.ts b/projects/app/src/pages/api/core/dataset/data/insertImages.ts index 3fbcecb235..f3ac6d0caf 100644 --- a/projects/app/src/pages/api/core/dataset/data/insertImages.ts +++ b/projects/app/src/pages/api/core/dataset/data/insertImages.ts @@ -49,8 +49,7 @@ async function handler( const planStatus = await getTeamPlanStatus({ teamId }); await authFrequencyLimit({ eventId: `${tmbId}-uploadfile`, - maxAmount: - planStatus.standardConstants?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, + maxAmount: planStatus.standard?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, expiredTime: addSeconds(new Date(), 30), // 30s num: result.fileMetadata.length }); diff --git a/projects/app/src/pages/api/core/dataset/presignDatasetFilePostUrl.ts b/projects/app/src/pages/api/core/dataset/presignDatasetFilePostUrl.ts index 7435b14de3..dca7c01733 100644 --- a/projects/app/src/pages/api/core/dataset/presignDatasetFilePostUrl.ts +++ b/projects/app/src/pages/api/core/dataset/presignDatasetFilePostUrl.ts @@ -25,16 +25,14 @@ async function handler( const planStatus = await getTeamPlanStatus({ teamId }); await authFrequencyLimit({ eventId: `${userId}-uploadfile`, - maxAmount: - planStatus.standardConstants?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, + maxAmount: planStatus.standard?.maxUploadFileCount || global.feConfigs.uploadFileMaxAmount, expiredTime: addSeconds(new Date(), 30) // 30s }); return await getS3DatasetSource().createUploadDatasetFileURL({ datasetId, filename, - maxFileSize: - planStatus.standardConstants?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize + maxFileSize: planStatus.standard?.maxUploadFileSize || global.feConfigs.uploadFileMaxSize }); } diff --git a/test/cases/service/support/permission/teamLimit.test.ts b/test/cases/service/support/permission/teamLimit.test.ts new file mode 100644 index 0000000000..07248c489b --- /dev/null +++ b/test/cases/service/support/permission/teamLimit.test.ts @@ -0,0 +1,780 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + checkTeamAIPoints, + checkTeamMemberLimit, + checkTeamAppTypeLimit, + checkDatasetIndexLimit, + checkTeamDatasetLimit, + checkTeamDatasetSyncPermission +} from '@fastgpt/service/support/permission/teamLimit'; +import * as walletUtils from '@fastgpt/service/support/wallet/sub/utils'; +import { MongoApp } from '@fastgpt/service/core/app/schema'; +import { MongoDataset } from '@fastgpt/service/core/dataset/schema'; +import { MongoTeamMember } from '@fastgpt/service/support/user/team/teamMemberSchema'; +import * as vectorController from '@fastgpt/service/common/vectorDB/controller'; +import { TeamErrEnum } from '@fastgpt/global/common/error/code/team'; +import { SystemErrEnum } from '@fastgpt/global/common/error/code/system'; +import { AppTypeEnum } from '@fastgpt/global/core/app/constants'; +import { TeamMemberStatusEnum } from '@fastgpt/global/support/user/team/constant'; +import { DatasetTypeEnum } from '@fastgpt/global/core/dataset/constants'; +import { StandardSubLevelEnum } from '@fastgpt/global/support/wallet/sub/constants'; + +// Valid ObjectId for testing +const mockTeamId = '507f1f77bcf86cd799439011'; + +describe('checkTeamAIPoints', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).subPlans; + }); + + it('当 global.subPlans.standard 不存在时直接返回', async () => { + const result = await checkTeamAIPoints(mockTeamId); + expect(result).toBeUndefined(); + }); + + it('当积分充足时返回积分信息', async () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + totalPoints: 2000 + } + } + }; + + vi.spyOn(walletUtils.teamPoint, 'getTeamPoints').mockResolvedValue({ + totalPoints: 2000, + surplusPoints: 1500, + usedPoints: 500 + }); + + const result = await checkTeamAIPoints(mockTeamId); + + expect(result).toEqual({ + totalPoints: 2000, + usedPoints: 500 + }); + }); + + it('当积分不足时抛出错误', async () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + totalPoints: 2000 + } + } + }; + + vi.spyOn(walletUtils.teamPoint, 'getTeamPoints').mockResolvedValue({ + totalPoints: 2000, + surplusPoints: 0, + usedPoints: 2000 + }); + + await expect(checkTeamAIPoints(mockTeamId)).rejects.toBe(TeamErrEnum.aiPointsNotEnough); + }); + + it('当已用积分等于总积分时抛出错误', async () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + totalPoints: 1000 + } + } + }; + + vi.spyOn(walletUtils.teamPoint, 'getTeamPoints').mockResolvedValue({ + totalPoints: 1000, + surplusPoints: 0, + usedPoints: 1000 + }); + + await expect(checkTeamAIPoints(mockTeamId)).rejects.toBe(TeamErrEnum.aiPointsNotEnough); + }); + + it('当已用积分超过总积分时抛出错误', async () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + totalPoints: 1000 + } + } + }; + + vi.spyOn(walletUtils.teamPoint, 'getTeamPoints').mockResolvedValue({ + totalPoints: 1000, + surplusPoints: -100, + usedPoints: 1100 + }); + + await expect(checkTeamAIPoints(mockTeamId)).rejects.toBe(TeamErrEnum.aiPointsNotEnough); + }); +}); + +describe('checkTeamMemberLimit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('当团队成员数量未超限时正常通过', async () => { + vi.spyOn(MongoTeamMember, 'countDocuments').mockResolvedValue(5); + + const mockStandard = { + standard: { + maxTeamMember: 10 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamMemberLimit(mockTeamId, 3)).resolves.toBeUndefined(); + }); + + it('当新增成员后超过限制时抛出错误', async () => { + vi.spyOn(MongoTeamMember, 'countDocuments').mockResolvedValue(8); + + const mockStandard = { + standard: { + maxTeamMember: 10 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamMemberLimit(mockTeamId, 3)).rejects.toBe(TeamErrEnum.teamOverSize); + }); + + it('当新增成员后刚好达到限制时正常通过', async () => { + vi.spyOn(MongoTeamMember, 'countDocuments').mockResolvedValue(8); + + const mockStandard = { + standard: { + maxTeamMember: 10 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamMemberLimit(mockTeamId, 2)).resolves.toBeUndefined(); + }); + + it('当 maxTeamMember 未设置时不限制', async () => { + vi.spyOn(MongoTeamMember, 'countDocuments').mockResolvedValue(100); + + const mockStandard = { + standard: {} + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamMemberLimit(mockTeamId, 50)).resolves.toBeUndefined(); + }); + + it('查询时排除已离开的成员', async () => { + const countSpy = vi.spyOn(MongoTeamMember, 'countDocuments').mockResolvedValue(5); + + const mockStandard = { + standard: { + maxTeamMember: 10 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await checkTeamMemberLimit(mockTeamId, 2); + + expect(countSpy).toHaveBeenCalledWith({ + teamId: mockTeamId, + status: { $ne: TeamMemberStatusEnum.leave } + }); + }); +}); + +describe('checkTeamAppTypeLimit', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).licenseData; + }); + + describe('app 类型检查', () => { + it('当应用数量未超限时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(30); + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app', + amount: 10 + }) + ).resolves.toBeUndefined(); + }); + + it('当应用数量超限时抛出错误', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(45); + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app', + amount: 10 + }) + ).rejects.toBe(TeamErrEnum.appAmountNotEnough); + }); + + it('当 maxAppAmount 未设置时不限制', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(100); + + const mockStandard = { + standard: {} + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app', + amount: 50 + }) + ).resolves.toBeUndefined(); + }); + + it('默认 amount 为 1', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(49); + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app' + }) + ).resolves.toBeUndefined(); + }); + + it('查询时只统计 chatAgent/simple/workflow 类型', async () => { + const countSpy = vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(30); + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app' + }); + + expect(countSpy).toHaveBeenCalledWith({ + teamId: mockTeamId, + type: { + $in: [AppTypeEnum.chatAgent, AppTypeEnum.simple, AppTypeEnum.workflow] + } + }); + }); + + it('当系统许可证限制存在且超限时抛出系统错误', async () => { + vi.spyOn(MongoApp, 'countDocuments') + .mockResolvedValueOnce(30) // 团队应用数 + .mockResolvedValueOnce(150); // 系统总应用数 + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + (global as any).licenseData = { + maxApps: 100 + }; + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app' + }) + ).rejects.toBe(SystemErrEnum.licenseAppAmountLimit); + }); + + it('当系统许可证限制存在但未超限时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValueOnce(30).mockResolvedValueOnce(80); + + const mockStandard = { + standard: { + maxAppAmount: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + (global as any).licenseData = { + maxApps: 100 + }; + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'app' + }) + ).resolves.toBeUndefined(); + }); + }); + + describe('tool 类型检查', () => { + it('当工具数量未超限时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(500); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'tool', + amount: 100 + }) + ).resolves.toBeUndefined(); + }); + + it('当工具数量超过 1000 时抛出错误', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(950); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'tool', + amount: 100 + }) + ).rejects.toBe(TeamErrEnum.pluginAmountNotEnough); + }); + + it('当工具数量刚好达到 1000 时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(999); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'tool', + amount: 1 + }) + ).resolves.toBeUndefined(); + }); + }); + + describe('folder 类型检查', () => { + it('当文件夹数量未超限时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(500); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'folder', + amount: 100 + }) + ).resolves.toBeUndefined(); + }); + + it('当文件夹数量超过 1000 时抛出错误', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(950); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'folder', + amount: 100 + }) + ).rejects.toBe(TeamErrEnum.appFolderAmountNotEnough); + }); + + it('当文件夹数量刚好达到 1000 时正常通过', async () => { + vi.spyOn(MongoApp, 'countDocuments').mockResolvedValue(999); + + await expect( + checkTeamAppTypeLimit({ + teamId: mockTeamId, + appCheckType: 'folder', + amount: 1 + }) + ).resolves.toBeUndefined(); + }); + }); +}); + +describe('checkDatasetIndexLimit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('当 standard 不存在时直接返回', async () => { + const mockPlanStatus = { + standard: undefined, + totalPoints: 1000, + usedPoints: 500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(5000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 100 + }) + ).resolves.toBeUndefined(); + }); + + it('当数据集大小未超限时正常通过', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(5000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 1000 + }) + ).resolves.toBeUndefined(); + }); + + it('当数据集大小超限时抛出错误', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(9500); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 1000 + }) + ).rejects.toBe(TeamErrEnum.datasetSizeNotEnough); + }); + + it('当数据集大小刚好达到限制时抛出错误', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(9000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 1000 + }) + ).rejects.toBe(TeamErrEnum.datasetSizeNotEnough); + }); + + it('当积分不足时抛出错误', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 2000, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(5000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 100 + }) + ).rejects.toBe(TeamErrEnum.aiPointsNotEnough); + }); + + it('当积分超支时抛出错误', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 2500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(5000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 100 + }) + ).rejects.toBe(TeamErrEnum.aiPointsNotEnough); + }); + + it('默认 insertLen 为 0', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 500, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(5000); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId + }) + ).resolves.toBeUndefined(); + }); + + it('数据集大小检查优先于积分检查', async () => { + const mockPlanStatus = { + standard: { + maxDatasetSize: 10000 + }, + totalPoints: 2000, + usedPoints: 2000, + datasetMaxSize: 10000 + }; + vi.spyOn(walletUtils, 'getTeamPlanStatus').mockResolvedValue(mockPlanStatus as any); + vi.spyOn(vectorController, 'getVectorCountByTeamId').mockResolvedValue(9500); + + await expect( + checkDatasetIndexLimit({ + teamId: mockTeamId, + insertLen: 1000 + }) + ).rejects.toBe(TeamErrEnum.datasetSizeNotEnough); + }); +}); + +describe('checkTeamDatasetLimit', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).licenseData; + }); + + it('当数据集数量未超限时正常通过', async () => { + vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValue(10); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetLimit(mockTeamId)).resolves.toBeUndefined(); + }); + + it('当数据集数量超限时抛出错误', async () => { + vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValue(20); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetLimit(mockTeamId)).rejects.toBe( + TeamErrEnum.datasetAmountNotEnough + ); + }); + + it('当数据集数量刚好达到限制时抛出错误', async () => { + vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValue(20); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetLimit(mockTeamId)).rejects.toBe( + TeamErrEnum.datasetAmountNotEnough + ); + }); + + it('当 maxDatasetAmount 未设置时不限制', async () => { + vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValue(100); + + const mockStandard = { + standard: {} + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetLimit(mockTeamId)).resolves.toBeUndefined(); + }); + + it('查询时排除文件夹类型', async () => { + const countSpy = vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValue(10); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await checkTeamDatasetLimit(mockTeamId); + + expect(countSpy).toHaveBeenCalledWith({ + teamId: mockTeamId, + type: { $ne: DatasetTypeEnum.folder } + }); + }); + + it('当系统许可证限制存在且超限时抛出系统错误', async () => { + vi.spyOn(MongoDataset, 'countDocuments') + .mockResolvedValueOnce(10) // 团队数据集数 + .mockResolvedValueOnce(150); // 系统总数据集数 + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + (global as any).licenseData = { + maxDatasets: 100 + }; + + await expect(checkTeamDatasetLimit(mockTeamId)).rejects.toBe( + SystemErrEnum.licenseDatasetAmountLimit + ); + }); + + it('当系统许可证限制存在但未超限时正常通过', async () => { + vi.spyOn(MongoDataset, 'countDocuments').mockResolvedValueOnce(10).mockResolvedValueOnce(80); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + (global as any).licenseData = { + maxDatasets: 100 + }; + + await expect(checkTeamDatasetLimit(mockTeamId)).resolves.toBeUndefined(); + }); + + it('系统许可证限制检查也排除文件夹类型', async () => { + const countSpy = vi + .spyOn(MongoDataset, 'countDocuments') + .mockResolvedValueOnce(10) + .mockResolvedValueOnce(80); + + const mockStandard = { + standard: { + maxDatasetAmount: 20 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + (global as any).licenseData = { + maxDatasets: 100 + }; + + await checkTeamDatasetLimit(mockTeamId); + + expect(countSpy).toHaveBeenNthCalledWith(2, { + type: { $ne: DatasetTypeEnum.folder } + }); + }); +}); + +describe('checkTeamDatasetSyncPermission', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('当套餐支持网站同步时正常通过', async () => { + const mockStandard = { + standard: { + websiteSyncPerDataset: 100 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetSyncPermission(mockTeamId)).resolves.toBeUndefined(); + }); + + it('当套餐不支持网站同步时抛出错误', async () => { + const mockStandard = { + standard: { + websiteSyncPerDataset: undefined + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetSyncPermission(mockTeamId)).rejects.toBe( + TeamErrEnum.websiteSyncNotEnough + ); + }); + + it('当 websiteSyncPerDataset 为 0 时抛出错误', async () => { + const mockStandard = { + standard: { + websiteSyncPerDataset: 0 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetSyncPermission(mockTeamId)).rejects.toBe( + TeamErrEnum.websiteSyncNotEnough + ); + }); + + it('当 standard 不存在时不抛出错误', async () => { + const mockStandard = { + standard: undefined + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetSyncPermission(mockTeamId)).resolves.toBeUndefined(); + }); + + it('当 websiteSyncPerDataset 为正数时正常通过', async () => { + const mockStandard = { + standard: { + websiteSyncPerDataset: 50 + } + }; + vi.spyOn(walletUtils, 'getTeamStandPlan').mockResolvedValue(mockStandard as any); + + await expect(checkTeamDatasetSyncPermission(mockTeamId)).resolves.toBeUndefined(); + }); +}); diff --git a/test/cases/service/support/wallet/sub/utils.test.ts b/test/cases/service/support/wallet/sub/utils.test.ts new file mode 100644 index 0000000000..c360276866 --- /dev/null +++ b/test/cases/service/support/wallet/sub/utils.test.ts @@ -0,0 +1,988 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + SubTypeEnum, + SubModeEnum, + StandardSubLevelEnum +} from '@fastgpt/global/support/wallet/sub/constants'; +import type { + TeamSubSchemaType, + TeamStandardSubPlanItemType, + StandSubPlanLevelMapType +} from '@fastgpt/global/support/wallet/sub/type'; +import { + buildStandardPlan, + getStandardPlansConfig, + getStandardPlanConfig, + sortStandPlans, + initTeamFreePlan, + getTeamStandPlan, + getTeamPlanStatus, + teamPoint, + teamQPM, + clearTeamPlanCache +} from '@fastgpt/service/support/wallet/sub/utils'; +import { MongoTeamSub } from '@fastgpt/service/support/wallet/sub/schema'; + +// Valid ObjectId for testing +const mockTeamId = '507f1f77bcf86cd799439011'; +const mockPlanId = '507f1f77bcf86cd799439012'; + +const baseStandard: TeamSubSchemaType = { + _id: mockPlanId, + teamId: mockTeamId, + type: SubTypeEnum.standard, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + currentSubLevel: StandardSubLevelEnum.basic, + nextSubLevel: StandardSubLevelEnum.basic, + totalPoints: 1000, + surplusPoints: 500, + currentExtraDatasetSize: 0 +}; + +const baseConstants: TeamStandardSubPlanItemType = { + price: 99, + name: 'Basic Plan', + desc: 'Basic description', + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + maxDatasetSize: 100, + chatHistoryStoreDuration: 30 +}; + +describe('getStandardPlansConfig', () => { + beforeEach(() => { + // 清理 global.subPlans + delete (global as any).subPlans; + }); + + it('返回 global.subPlans.standard 配置', () => { + const mockStandardPlans: StandSubPlanLevelMapType = { + [StandardSubLevelEnum.free]: { + price: 0, + totalPoints: 100, + maxTeamMember: 1, + maxAppAmount: 5, + maxDatasetAmount: 2, + chatHistoryStoreDuration: 7, + maxDatasetSize: 10 + }, + [StandardSubLevelEnum.basic]: { + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + chatHistoryStoreDuration: 30, + maxDatasetSize: 100 + }, + [StandardSubLevelEnum.advanced]: { + price: 299, + totalPoints: 10000, + maxTeamMember: 50, + maxAppAmount: 200, + maxDatasetAmount: 100, + chatHistoryStoreDuration: 90, + maxDatasetSize: 500 + }, + [StandardSubLevelEnum.custom]: { + price: 999, + totalPoints: 50000, + maxTeamMember: 200, + maxAppAmount: 1000, + maxDatasetAmount: 500, + chatHistoryStoreDuration: 365, + maxDatasetSize: 2000 + }, + [StandardSubLevelEnum.experience]: { + price: 0, + totalPoints: 500, + maxTeamMember: 3, + maxAppAmount: 10, + maxDatasetAmount: 5, + chatHistoryStoreDuration: 14, + maxDatasetSize: 30 + }, + [StandardSubLevelEnum.team]: { + price: 199, + totalPoints: 5000, + maxTeamMember: 20, + maxAppAmount: 100, + maxDatasetAmount: 50, + chatHistoryStoreDuration: 60, + maxDatasetSize: 300 + }, + [StandardSubLevelEnum.enterprise]: { + price: 599, + totalPoints: 30000, + maxTeamMember: 100, + maxAppAmount: 500, + maxDatasetAmount: 300, + chatHistoryStoreDuration: 180, + maxDatasetSize: 1000 + } + }; + + (global as any).subPlans = { + standard: mockStandardPlans + }; + + const result = getStandardPlansConfig(); + expect(result).toBe(mockStandardPlans); + expect(result).toHaveProperty(StandardSubLevelEnum.free); + expect(result).toHaveProperty(StandardSubLevelEnum.basic); + expect(result).toHaveProperty(StandardSubLevelEnum.advanced); + }); + + it('global.subPlans 不存在时返回 undefined', () => { + const result = getStandardPlansConfig(); + expect(result).toBeUndefined(); + }); + + it('global.subPlans 存在但 standard 不存在时返回 undefined', () => { + (global as any).subPlans = {}; + const result = getStandardPlansConfig(); + expect(result).toBeUndefined(); + }); +}); + +describe('getStandardPlanConfig', () => { + beforeEach(() => { + delete (global as any).subPlans; + }); + + it('返回指定 level 的套餐配置', () => { + const mockBasicPlan: TeamStandardSubPlanItemType = { + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + chatHistoryStoreDuration: 30, + maxDatasetSize: 100 + }; + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: mockBasicPlan + } + }; + + const result = getStandardPlanConfig(StandardSubLevelEnum.basic); + expect(result).toBe(mockBasicPlan); + expect(result?.price).toBe(99); + expect(result?.totalPoints).toBe(2000); + }); + + it('返回 free 级别配置', () => { + const mockFreePlan: TeamStandardSubPlanItemType = { + price: 0, + totalPoints: 100, + maxTeamMember: 1, + maxAppAmount: 5, + maxDatasetAmount: 2, + chatHistoryStoreDuration: 7, + maxDatasetSize: 10 + }; + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.free]: mockFreePlan + } + }; + + const result = getStandardPlanConfig(StandardSubLevelEnum.free); + expect(result).toBe(mockFreePlan); + }); + + it('返回 advanced 级别配置', () => { + const mockAdvancedPlan: TeamStandardSubPlanItemType = { + price: 299, + totalPoints: 10000, + maxTeamMember: 50, + maxAppAmount: 200, + maxDatasetAmount: 100, + chatHistoryStoreDuration: 90, + maxDatasetSize: 500 + }; + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.advanced]: mockAdvancedPlan + } + }; + + const result = getStandardPlanConfig(StandardSubLevelEnum.advanced); + expect(result).toBe(mockAdvancedPlan); + }); + + it('返回 custom 级别配置', () => { + const mockCustomPlan: TeamStandardSubPlanItemType = { + price: 999, + totalPoints: 50000, + maxTeamMember: 200, + maxAppAmount: 1000, + maxDatasetAmount: 500, + chatHistoryStoreDuration: 365, + maxDatasetSize: 2000 + }; + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.custom]: mockCustomPlan + } + }; + + const result = getStandardPlanConfig(StandardSubLevelEnum.custom); + expect(result).toBe(mockCustomPlan); + }); + + it('global.subPlans 不存在时返回 undefined', () => { + const result = getStandardPlanConfig(StandardSubLevelEnum.basic); + expect(result).toBeUndefined(); + }); + + it('global.subPlans.standard 不存在时返回 undefined', () => { + (global as any).subPlans = {}; + const result = getStandardPlanConfig(StandardSubLevelEnum.basic); + expect(result).toBeUndefined(); + }); + + it('指定的 level 不存在时返回 undefined', () => { + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.free]: { + price: 0, + totalPoints: 100, + maxTeamMember: 1, + maxAppAmount: 5, + maxDatasetAmount: 2, + chatHistoryStoreDuration: 7, + maxDatasetSize: 10 + } + } + }; + + const result = getStandardPlanConfig(StandardSubLevelEnum.basic); + expect(result).toBeUndefined(); + }); +}); + +describe('sortStandPlans', () => { + const createMockPlan = ( + level: StandardSubLevelEnum, + id: string = '507f1f77bcf86cd799439011' + ): TeamSubSchemaType => ({ + _id: id, + teamId: '507f1f77bcf86cd799439012', + type: SubTypeEnum.standard, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + currentSubLevel: level, + nextSubLevel: level, + totalPoints: 1000, + surplusPoints: 500, + currentExtraDatasetSize: 0 + }); + + it('按 weight 降序排序:custom(7) > enterprise(6) > advanced(5)', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.advanced, 'id1'), + createMockPlan(StandardSubLevelEnum.custom, 'id2'), + createMockPlan(StandardSubLevelEnum.enterprise, 'id3') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted[0].currentSubLevel).toBe(StandardSubLevelEnum.custom); // weight: 7 + expect(sorted[1].currentSubLevel).toBe(StandardSubLevelEnum.enterprise); // weight: 6 + expect(sorted[2].currentSubLevel).toBe(StandardSubLevelEnum.advanced); // weight: 5 + }); + + it('按 weight 降序排序:basic(4) > team(3) > experience(2) > free(1)', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.free, 'id1'), + createMockPlan(StandardSubLevelEnum.experience, 'id2'), + createMockPlan(StandardSubLevelEnum.team, 'id3'), + createMockPlan(StandardSubLevelEnum.basic, 'id4') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted[0].currentSubLevel).toBe(StandardSubLevelEnum.basic); // weight: 4 + expect(sorted[1].currentSubLevel).toBe(StandardSubLevelEnum.team); // weight: 3 + expect(sorted[2].currentSubLevel).toBe(StandardSubLevelEnum.experience); // weight: 2 + expect(sorted[3].currentSubLevel).toBe(StandardSubLevelEnum.free); // weight: 1 + }); + + it('完整排序:custom > enterprise > advanced > basic > team > experience > free', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.free, 'id1'), + createMockPlan(StandardSubLevelEnum.basic, 'id2'), + createMockPlan(StandardSubLevelEnum.advanced, 'id3'), + createMockPlan(StandardSubLevelEnum.custom, 'id4'), + createMockPlan(StandardSubLevelEnum.experience, 'id5'), + createMockPlan(StandardSubLevelEnum.team, 'id6'), + createMockPlan(StandardSubLevelEnum.enterprise, 'id7') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted[0].currentSubLevel).toBe(StandardSubLevelEnum.custom); // 7 + expect(sorted[1].currentSubLevel).toBe(StandardSubLevelEnum.enterprise); // 6 + expect(sorted[2].currentSubLevel).toBe(StandardSubLevelEnum.advanced); // 5 + expect(sorted[3].currentSubLevel).toBe(StandardSubLevelEnum.basic); // 4 + expect(sorted[4].currentSubLevel).toBe(StandardSubLevelEnum.team); // 3 + expect(sorted[5].currentSubLevel).toBe(StandardSubLevelEnum.experience); // 2 + expect(sorted[6].currentSubLevel).toBe(StandardSubLevelEnum.free); // 1 + }); + + it('单个元素数组保持不变', () => { + const plans = [createMockPlan(StandardSubLevelEnum.basic)]; + const sorted = sortStandPlans(plans); + + expect(sorted).toHaveLength(1); + expect(sorted[0].currentSubLevel).toBe(StandardSubLevelEnum.basic); + }); + + it('空数组返回空数组', () => { + const plans: TeamSubSchemaType[] = []; + const sorted = sortStandPlans(plans); + + expect(sorted).toHaveLength(0); + }); + + it('相同 level 的多个套餐保持相对顺序稳定', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.basic, 'id1'), + createMockPlan(StandardSubLevelEnum.basic, 'id2'), + createMockPlan(StandardSubLevelEnum.basic, 'id3') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted).toHaveLength(3); + expect(sorted[0]._id).toBe('id1'); + expect(sorted[1]._id).toBe('id2'); + expect(sorted[2]._id).toBe('id3'); + }); + + it('修改原数组(sort 是 in-place 操作)', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.free, 'id1'), + createMockPlan(StandardSubLevelEnum.advanced, 'id2') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted).toBe(plans); // 返回的是同一个数组引用 + expect(plans[0].currentSubLevel).toBe(StandardSubLevelEnum.advanced); + expect(plans[1].currentSubLevel).toBe(StandardSubLevelEnum.free); + }); + + it('混合不同 level 的套餐正确排序', () => { + const plans = [ + createMockPlan(StandardSubLevelEnum.basic, 'id1'), + createMockPlan(StandardSubLevelEnum.free, 'id2'), + createMockPlan(StandardSubLevelEnum.custom, 'id3'), + createMockPlan(StandardSubLevelEnum.advanced, 'id4') + ]; + + const sorted = sortStandPlans(plans); + + expect(sorted[0]._id).toBe('id3'); // custom + expect(sorted[1]._id).toBe('id4'); // advanced + expect(sorted[2]._id).toBe('id1'); // basic + expect(sorted[3]._id).toBe('id2'); // free + }); +}); + +describe('buildStandardPlan', () => { + describe('展示字段始终取自 standardConstants', () => { + it('name/desc/price 取自 standardConstants', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.name).toBe('Basic Plan'); + expect(result.desc).toBe('Basic description'); + expect(result.price).toBe(99); + }); + + it('priceDescription/customFormUrl/customDescriptions 取自 standardConstants', () => { + const constants: TeamStandardSubPlanItemType = { + ...baseConstants, + priceDescription: '联系销售', + customFormUrl: 'https://example.com/form', + customDescriptions: ['特性A', '特性B'] + }; + const result = buildStandardPlan(baseStandard, constants); + expect(result.priceDescription).toBe('联系销售'); + expect(result.customFormUrl).toBe('https://example.com/form'); + expect(result.customDescriptions).toEqual(['特性A', '特性B']); + }); + + it('wecom 取自 standardConstants', () => { + const constants: TeamStandardSubPlanItemType = { + ...baseConstants, + wecom: { price: 9.9, points: 500 } + }; + const result = buildStandardPlan(baseStandard, constants); + expect(result.wecom).toEqual({ price: 9.9, points: 500 }); + }); + + it('standardConstants 展示字段为 undefined 时结果也为 undefined', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.priceDescription).toBeUndefined(); + expect(result.customFormUrl).toBeUndefined(); + expect(result.customDescriptions).toBeUndefined(); + expect(result.wecom).toBeUndefined(); + }); + }); + + describe('DB 元数据从 standard spread', () => { + it('携带 _id/teamId/type/时间/模式等字段', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result._id).toBe(mockPlanId); + expect(result.teamId).toBe(mockTeamId); + expect(result.type).toBe(SubTypeEnum.standard); + expect(result.startTime).toEqual(new Date('2024-01-01')); + expect(result.expiredTime).toEqual(new Date('2025-01-01')); + expect(result.currentMode).toBe(SubModeEnum.month); + expect(result.nextMode).toBe(SubModeEnum.month); + expect(result.currentSubLevel).toBe(StandardSubLevelEnum.basic); + expect(result.nextSubLevel).toBe(StandardSubLevelEnum.basic); + }); + + it('totalPoints/surplusPoints/currentExtraDatasetSize 来自 standard', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + // standard.totalPoints=1000,standardConstants.totalPoints=2000,应取 standard + expect(result.totalPoints).toBe(1000); + expect(result.surplusPoints).toBe(500); + expect(result.currentExtraDatasetSize).toBe(0); + }); + + it('annualBonusPoints 来自 standard', () => { + const standard: TeamSubSchemaType = { ...baseStandard, annualBonusPoints: 200 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.annualBonusPoints).toBe(200); + }); + }); + + describe('限制字段:DB override 优先', () => { + it('standard.maxTeamMember 有值时取 standard 的值', () => { + const standard: TeamSubSchemaType = { ...baseStandard, maxTeamMember: 5 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.maxTeamMember).toBe(5); + }); + + it('standard.maxTeamMember 为 undefined 时回退到 standardConstants', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.maxTeamMember).toBe(10); + }); + + it('standard.requestsPerMinute 有值时取 standard 的值', () => { + const standard: TeamSubSchemaType = { ...baseStandard, requestsPerMinute: 60 }; + const constants: TeamStandardSubPlanItemType = { ...baseConstants, requestsPerMinute: 30 }; + const result = buildStandardPlan(standard, constants); + expect(result.requestsPerMinute).toBe(60); + }); + + it('standard.requestsPerMinute 为 undefined 时回退到 standardConstants', () => { + const constants: TeamStandardSubPlanItemType = { ...baseConstants, requestsPerMinute: 30 }; + const result = buildStandardPlan(baseStandard, constants); + expect(result.requestsPerMinute).toBe(30); + }); + + it('standard.chatHistoryStoreDuration 有值时取 standard 的值', () => { + const standard: TeamSubSchemaType = { ...baseStandard, chatHistoryStoreDuration: 90 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.chatHistoryStoreDuration).toBe(90); + }); + + it('standard.chatHistoryStoreDuration 为 undefined 时回退到 standardConstants', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.chatHistoryStoreDuration).toBe(30); + }); + + it('standard.maxDatasetSize 有值时取 standard 的值', () => { + const standard: TeamSubSchemaType = { ...baseStandard, maxDatasetSize: 200 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.maxDatasetSize).toBe(200); + }); + + it('standard.maxDatasetSize 为 undefined 时回退到 standardConstants', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.maxDatasetSize).toBe(100); + }); + + it('所有可选限制字段 DB override 行为一致', () => { + const standard: TeamSubSchemaType = { + ...baseStandard, + websiteSyncPerDataset: 100, + appRegistrationCount: 5, + auditLogStoreDuration: 180, + ticketResponseTime: 4, + customDomain: 3, + maxUploadFileSize: 512, + maxUploadFileCount: 20 + }; + const constants: TeamStandardSubPlanItemType = { + ...baseConstants, + websiteSyncPerDataset: 50, + appRegistrationCount: 2, + auditLogStoreDuration: 90, + ticketResponseTime: 8, + customDomain: 1, + maxUploadFileSize: 256, + maxUploadFileCount: 10 + }; + const result = buildStandardPlan(standard, constants); + expect(result.websiteSyncPerDataset).toBe(100); + expect(result.appRegistrationCount).toBe(5); + expect(result.auditLogStoreDuration).toBe(180); + expect(result.ticketResponseTime).toBe(4); + expect(result.customDomain).toBe(3); + expect(result.maxUploadFileSize).toBe(512); + expect(result.maxUploadFileCount).toBe(20); + }); + + it('可选限制字段全部 undefined 时回退到 standardConstants', () => { + const constants: TeamStandardSubPlanItemType = { + ...baseConstants, + websiteSyncPerDataset: 50, + appRegistrationCount: 2, + auditLogStoreDuration: 90, + ticketResponseTime: 8, + customDomain: 1, + maxUploadFileSize: 256, + maxUploadFileCount: 10 + }; + const result = buildStandardPlan(baseStandard, constants); + expect(result.websiteSyncPerDataset).toBe(50); + expect(result.appRegistrationCount).toBe(2); + expect(result.auditLogStoreDuration).toBe(90); + expect(result.ticketResponseTime).toBe(8); + expect(result.customDomain).toBe(1); + expect(result.maxUploadFileSize).toBe(256); + expect(result.maxUploadFileCount).toBe(10); + }); + }); + + describe('字段名映射:maxApp → maxAppAmount,maxDataset → maxDatasetAmount', () => { + it('standard.maxApp 映射到结果的 maxAppAmount', () => { + const standard: TeamSubSchemaType = { ...baseStandard, maxApp: 100 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.maxAppAmount).toBe(100); + }); + + it('standard.maxApp 为 undefined 时 maxAppAmount 回退到 standardConstants.maxAppAmount', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.maxAppAmount).toBe(50); + }); + + it('standard.maxDataset 映射到结果的 maxDatasetAmount', () => { + const standard: TeamSubSchemaType = { ...baseStandard, maxDataset: 30 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.maxDatasetAmount).toBe(30); + }); + + it('standard.maxDataset 为 undefined 时 maxDatasetAmount 回退到 standardConstants.maxDatasetAmount', () => { + const result = buildStandardPlan(baseStandard, baseConstants); + expect(result.maxDatasetAmount).toBe(20); + }); + + it('standard.maxApp 和 standard.maxDataset 同时有值', () => { + const standard: TeamSubSchemaType = { ...baseStandard, maxApp: 88, maxDataset: 44 }; + const result = buildStandardPlan(standard, baseConstants); + expect(result.maxAppAmount).toBe(88); + expect(result.maxDatasetAmount).toBe(44); + }); + }); +}); + +describe('initTeamFreePlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).subPlans; + }); + + it('创建新的免费套餐(不存在时)', async () => { + const teamId = mockTeamId; + + vi.spyOn(MongoTeamSub, 'findOne').mockResolvedValue(null); + const mockCreatedPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.free + }; + vi.spyOn(MongoTeamSub, 'create').mockResolvedValue([mockCreatedPlan] as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.free]: { + totalPoints: 100 + } + } + }; + + const result = await initTeamFreePlan({ teamId }); + + expect(MongoTeamSub.findOne).toHaveBeenCalled(); + expect(MongoTeamSub.create).toHaveBeenCalled(); + expect(result).toEqual([mockCreatedPlan]); + }); + + it('重置已存在的免费套餐', async () => { + const teamId = mockTeamId; + const mockExistingPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.free, + nextSubLevel: StandardSubLevelEnum.free, + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2024-12-01'), + totalPoints: 100, + surplusPoints: -50, + currentExtraDatasetSize: 0, + save: vi.fn().mockResolvedValue(true) + }; + + vi.spyOn(MongoTeamSub, 'findOne').mockResolvedValue(mockExistingPlan as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.free]: { + totalPoints: 100 + } + } + }; + + await initTeamFreePlan({ teamId }); + + expect(mockExistingPlan.surplusPoints).toBe(50); // -50 + 100 + expect(mockExistingPlan.save).toHaveBeenCalled(); + }); +}); + +describe('getTeamStandPlan', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).subPlans; + }); + + it('返回团队标准套餐', async () => { + const teamId = mockTeamId; + const mockPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.basic, + totalPoints: 2000, + surplusPoints: 1500, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.basic, + currentExtraDatasetSize: 0 + }; + + // getTeamStandPlan 不使用 .lean(),直接返回数组 + vi.spyOn(MongoTeamSub, 'find').mockResolvedValue([mockPlan] as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + name: 'Basic Plan', + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + maxDatasetSize: 100, + chatHistoryStoreDuration: 30 + } + } + }; + + const result = await getTeamStandPlan({ teamId }); + + expect(MongoTeamSub.find).toHaveBeenCalled(); + expect(result[SubTypeEnum.standard]).toBeDefined(); + expect(result[SubTypeEnum.standard]?.name).toBe('Basic Plan'); + }); +}); + +describe('getTeamPlanStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).subPlans; + }); + + it('返回团队套餐状态(包含标准套餐)', async () => { + const teamId = mockTeamId; + const mockStandardPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.basic, + totalPoints: 2000, + surplusPoints: 1500, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.basic, + currentExtraDatasetSize: 0, + maxDatasetSize: 100 + }; + + // getTeamPlanStatus 使用 .lean(),需要 mock 返回带 lean 方法的对象 + const mockQuery = { + lean: vi.fn().mockResolvedValue([mockStandardPlan]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + name: 'Basic Plan', + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + maxDatasetSize: 100, + chatHistoryStoreDuration: 30 + } + } + }; + + const result = await getTeamPlanStatus({ teamId }); + + expect(result.totalPoints).toBe(2000); + expect(result.usedPoints).toBe(500); // 2000 - 1500 + expect(result.datasetMaxSize).toBe(100); + expect(result[SubTypeEnum.standard]).toBeDefined(); + }); + + it('包含额外积分套餐', async () => { + const teamId = mockTeamId; + const mockStandardPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.basic, + totalPoints: 2000, + surplusPoints: 1500, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.basic, + currentExtraDatasetSize: 0 + }; + + const mockExtraPointsPlan = { + _id: '507f1f77bcf86cd799439013', + teamId, + type: SubTypeEnum.extraPoints, + totalPoints: 5000, + surplusPoints: 3000, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01') + }; + + // getTeamPlanStatus 使用 .lean() + const mockQuery = { + lean: vi.fn().mockResolvedValue([mockStandardPlan, mockExtraPointsPlan]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + name: 'Basic Plan', + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + maxDatasetSize: 100, + chatHistoryStoreDuration: 30 + } + } + }; + + const result = await getTeamPlanStatus({ teamId }); + + expect(result.totalPoints).toBe(7000); // 2000 + 5000 + expect(result.usedPoints).toBe(2500); // 7000 - (1500 + 3000) + }); + + it('包含额外数据集大小套餐', async () => { + const teamId = mockTeamId; + const mockStandardPlan = { + _id: mockPlanId, + teamId, + type: SubTypeEnum.standard, + currentSubLevel: StandardSubLevelEnum.basic, + totalPoints: 2000, + surplusPoints: 1500, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01'), + currentMode: SubModeEnum.month, + nextMode: SubModeEnum.month, + nextSubLevel: StandardSubLevelEnum.basic, + currentExtraDatasetSize: 0, + maxDatasetSize: 100 + }; + + const mockExtraDatasetPlan = { + _id: '507f1f77bcf86cd799439013', + teamId, + type: SubTypeEnum.extraDatasetSize, + currentExtraDatasetSize: 200, + totalPoints: 0, + surplusPoints: 0, + startTime: new Date('2024-01-01'), + expiredTime: new Date('2025-01-01') + }; + + // getTeamPlanStatus 使用 .lean() + const mockQuery = { + lean: vi.fn().mockResolvedValue([mockStandardPlan, mockExtraDatasetPlan]) + }; + vi.spyOn(MongoTeamSub, 'find').mockReturnValue(mockQuery as any); + + (global as any).subPlans = { + standard: { + [StandardSubLevelEnum.basic]: { + name: 'Basic Plan', + price: 99, + totalPoints: 2000, + maxTeamMember: 10, + maxAppAmount: 50, + maxDatasetAmount: 20, + maxDatasetSize: 100, + chatHistoryStoreDuration: 30 + } + } + }; + + const result = await getTeamPlanStatus({ teamId }); + + expect(result.datasetMaxSize).toBe(300); // 100 + 200 + }); +}); + +describe('teamPoint', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('getTeamPoints', () => { + it('从缓存获取积分信息', async () => { + const teamId = mockTeamId; + + // 全局 mock 会自动处理 Redis 调用 + const result = await teamPoint.getTeamPoints({ teamId }); + + expect(result).toHaveProperty('totalPoints'); + expect(result).toHaveProperty('surplusPoints'); + expect(result).toHaveProperty('usedPoints'); + }); + }); + + describe('incrTeamPointsCache', () => { + it('增加团队积分缓存', async () => { + const teamId = mockTeamId; + const value = 100; + + // 全局 mock 会自动处理 Redis 调用 + await expect(teamPoint.incrTeamPointsCache({ teamId, value })).resolves.not.toThrow(); + }); + }); + + describe('updateTeamPointsCache', () => { + it('更新团队积分缓存', async () => { + const teamId = mockTeamId; + const totalPoints = 2000; + const surplusPoints = 1500; + + // 全局 mock 会自动处理 Redis 调用 + await expect( + teamPoint.updateTeamPointsCache({ teamId, totalPoints, surplusPoints }) + ).resolves.not.toThrow(); + }); + }); + + describe('clearTeamPointsCache', () => { + it('清除团队积分缓存', async () => { + const teamId = mockTeamId; + + // 全局 mock 会自动处理 Redis 调用 + await expect(teamPoint.clearTeamPointsCache(teamId)).resolves.not.toThrow(); + }); + }); +}); + +describe('teamQPM', () => { + beforeEach(() => { + vi.clearAllMocks(); + delete (global as any).subPlans; + }); + + describe('getTeamQPMLimit', () => { + it('获取团队 QPM 限制', async () => { + const teamId = mockTeamId; + + // 全局 mock 会自动处理 Redis 和数据库调用 + const result = await teamQPM.getTeamQPMLimit(teamId); + + expect(typeof result === 'number' || result === null).toBe(true); + }); + }); + + describe('setCachedTeamQPMLimit', () => { + it('设置团队 QPM 限制缓存', async () => { + const teamId = mockTeamId; + const limit = 60; + + // 全局 mock 会自动处理 Redis 调用 + await expect(teamQPM.setCachedTeamQPMLimit(teamId, limit)).resolves.not.toThrow(); + }); + }); + + describe('clearTeamQPMLimitCache', () => { + it('清除团队 QPM 限制缓存', async () => { + const teamId = mockTeamId; + + // 全局 mock 会自动处理 Redis 调用 + await expect(teamQPM.clearTeamQPMLimitCache(teamId)).resolves.not.toThrow(); + }); + }); +}); + +describe('clearTeamPlanCache', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('清除团队套餐相关的所有缓存', async () => { + const teamId = mockTeamId; + + await clearTeamPlanCache(teamId); + + // 函数应该被调用(具体实现会调用 teamPoint 和 teamQPM 的清除方法) + expect(teamId).toBeDefined(); + }); +});