Compare commits
237 Commits
v1.0.0-rc.4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a165c87626 | |||
| 94321510cd | |||
| 675bac8151 | |||
| de471d0fa0 | |||
| ea5b152313 | |||
| 75e608bdc8 | |||
| c52ff411a7 | |||
| ff407c4607 | |||
| ae380a5a81 | |||
| 71519301e4 | |||
| 497ed7c39a | |||
| 8be5131c29 | |||
| 6a9fd7611a | |||
| c42757f9af | |||
| c450214d78 | |||
| 73e701e49a | |||
| 537c292801 | |||
| b330253fe2 | |||
| d703ccbb79 | |||
| 697ef3fcca | |||
| 1249eebf46 | |||
| a34c284017 | |||
| 3b2c0a0471 | |||
| 207f98252d | |||
| 113b9c8ecb | |||
| a4c069f88d | |||
| 7caf77db63 | |||
| 6faf989549 | |||
| 719d06ecd6 | |||
| 5464d14973 | |||
| 446e1ce10b | |||
| cd8cdebdcb | |||
| 790d832756 | |||
| 63c83d5abd | |||
| 64326a86d5 | |||
| 5c4ed6206e | |||
| 43f9869246 | |||
| 8eb31a7c82 | |||
| ca47fd18f5 | |||
| 296120641a | |||
| 5d18485e9a | |||
| 60fc1eee31 | |||
| da8cf3eef0 | |||
| 1ddffd236c | |||
| 5eeb3c9f18 | |||
| 2d8bdc1b7c | |||
| 2723bd66ad | |||
| 2c4c2002c6 | |||
| 8cf49db0a8 | |||
| 53a884a6fe | |||
| 60d63f87ff | |||
| 603886d422 | |||
| 3c056b00b1 | |||
| e9d408e718 | |||
| c175a41a2b | |||
| 4e335a9997 | |||
| 6f1f8b60f1 | |||
| a318279d5f | |||
| d1542a65ac | |||
| 26ac8b5dc1 | |||
| a8a96a7e60 | |||
| 45f676514a | |||
| 9e4ab6e7bb | |||
| 45aea22451 | |||
| 5b9aa9e77a | |||
| 80bf73b41c | |||
| e83ec743c8 | |||
| 824c6d9133 | |||
| 76f8112ee7 | |||
| 8afd1d1247 | |||
| b4b58ee887 | |||
| 97ddfeab59 | |||
| 6db071543c | |||
| d83db15801 | |||
| 2780f71b8f | |||
| 052da457eb | |||
| 6f415428d3 | |||
| 59a93cf5c7 | |||
| 867d8acfc3 | |||
| 30d3a3a5f7 | |||
| d2576ddcd3 | |||
| 4ca47ee236 | |||
| 16dd7237c0 | |||
| 1915344838 | |||
| 15ff8e0268 | |||
| a1c82841b5 | |||
| 1e6f31b235 | |||
| 2eaa943d9f | |||
| 7a5348caa3 | |||
| f5753a2b31 | |||
| 4dd68bad52 | |||
| 0f043ae404 | |||
| 75c05bb4b8 | |||
| 81d3dc08e5 | |||
| adc390c5fb | |||
| e8c36762fd | |||
| e2dbd02cbb | |||
| c8d3768087 | |||
| 32805849d6 | |||
| 01c2128e23 | |||
| 189913b7a0 | |||
| d2f7f9ee3a | |||
| 83068d115e | |||
| 4a188deeaa | |||
| 933ea0cddc | |||
| b53319361f | |||
| 5681c92b3f | |||
| 6e5a359110 | |||
| 87cc22d7ec | |||
| 3aa113b5a3 | |||
| 77d3157592 | |||
| 00d23abf64 | |||
| 580ad97c02 | |||
| 39e05118ff | |||
| 9e59ffc3d8 | |||
| abad0d3cc0 | |||
| b0ac0429cf | |||
| d17b566bcc | |||
| 7aaa533265 | |||
| 7791b78429 | |||
| cb5c0453f5 | |||
| 4d20e053cb | |||
| 0ff9c35e62 | |||
| 0bbcaa8999 | |||
| 1e9ff8a0de | |||
| 9a2e60dff2 | |||
| b596de739d | |||
| 45d54c1613 | |||
| 086044650d | |||
| 0c7aceb831 | |||
| b2e25b7df2 | |||
| 230a3592f8 | |||
| afb470e405 | |||
| 1588027084 | |||
| 38bf2d8daa | |||
| e8c836d705 | |||
| 979aeceb5c | |||
| e79cee1e9e | |||
| 63ead2bf7f | |||
| 5b86ce0d70 | |||
| 74985fa877 | |||
| 1d32037364 | |||
| dc245ae764 | |||
| f8add4ca49 | |||
| 65f8afe922 | |||
| 5bc4c74813 | |||
| 30025aeba3 | |||
| c91ba0c4eb | |||
| f223db9330 | |||
| 9e283ab10b | |||
| a8b7c92e5f | |||
| 6b6c9904ac | |||
| 1011934987 | |||
| bc8110ce36 | |||
| ad224ecf5b | |||
| a64f26d1d2 | |||
| 3360882642 | |||
| b37b6d80b3 | |||
| 3d850d38b6 | |||
| 349d5429ca | |||
| 465c5edab9 | |||
| ff06067a18 | |||
| 51ca897cf4 | |||
| 1288028181 | |||
| 2a528d46cb | |||
| 583da45296 | |||
| b302be30e3 | |||
| 88437a1869 | |||
| b08febaa3c | |||
| 92a0959448 | |||
| 49bc3a1175 | |||
| 0354c38bef | |||
| ebbe315533 | |||
| fddf54ccc5 | |||
| b9bc6f0e21 | |||
| f2c7647ecf | |||
| 19f1821fc8 | |||
| 8e5e89bb5b | |||
| e13d673454 | |||
| ae6a03364d | |||
| 006e801652 | |||
| 6f11d19877 | |||
| 58ba867dd6 | |||
| 20d3e73734 | |||
| 2d1ca15384 | |||
| 0d4b25795a | |||
| 146dd77b83 | |||
| 5e88f97ac1 | |||
| 0cd9a3a068 | |||
| 032993ed49 | |||
| c78573ce03 | |||
| 8db32213e7 | |||
| cb9270ed23 | |||
| fc08c133e2 | |||
| b397c58bab | |||
| 8ae095c3b8 | |||
| 04b4483d7d | |||
| ee9736bbc8 | |||
| 0936e25046 | |||
| 5dd0d3bcbd | |||
| f69ceb6967 | |||
| 68830e6097 | |||
| 2d968c3eab | |||
| cb7a61466e | |||
| 132d7b9f94 | |||
| 6f8668e4c3 | |||
| 8a10dedb7d | |||
| 554defe4f4 | |||
| 8f9ee9ba88 | |||
| 3caa6e467b | |||
| 18282e610d | |||
| 51b5cbe1bd | |||
| 3e588b4d4f | |||
| 0526a22643 | |||
| aa56667b8f | |||
| 428e3d91f2 | |||
| 3856b9d2c0 | |||
| 469d3747af | |||
| a720064d91 | |||
| fde2cac9d3 | |||
| 2b89989f62 | |||
| 7fe896d2f8 | |||
| 3057f04a17 | |||
| 03d537328a | |||
| 19fc384e67 | |||
| ba474393fb | |||
| 5fa103fa5b | |||
| 543cc64ea3 | |||
| d146e45e2f | |||
| 560ba57c88 | |||
| 948780e3fa | |||
| c19d5aa663 | |||
| faa0f1425a | |||
| a7475a1e67 | |||
| 415d21d071 | |||
| abc255dd6d | |||
| a7d019e3a9 |
+6
-1
@@ -7,4 +7,9 @@ Makefile
|
||||
docs
|
||||
.eslintcache
|
||||
.gocache
|
||||
/web/node_modules
|
||||
/web/node_modules
|
||||
/web/default/node_modules
|
||||
/web/default/dist
|
||||
/web/classic/node_modules
|
||||
/web/classic/dist
|
||||
!THIRD-PARTY-LICENSES.md
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
# 对话超时设置
|
||||
# 所有请求超时时间,单位秒,默认为0,表示不限制
|
||||
# RELAY_TIMEOUT=0
|
||||
# Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制
|
||||
# RELAY_IDLE_CONN_TIMEOUT=90
|
||||
# 流模式无响应超时时间,单位秒,如果出现空补全可以尝试改为更大值
|
||||
# STREAMING_TIMEOUT=300
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve tag & write VERSION
|
||||
id: version
|
||||
run: |
|
||||
if echo "${{ github.ref }}" | grep -q "^refs/tags/"; then
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
else
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
TAG="dev-${SHORT_SHA}"
|
||||
fi
|
||||
echo "TAG=${TAG}" >> $GITHUB_ENV
|
||||
echo "${TAG}" > VERSION
|
||||
echo "Building tag: ${TAG}"
|
||||
cat VERSION
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building image with tag: ${{ env.TAG }}"
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.TAG }} \
|
||||
-t git.viaeon.com/admin/new-api:latest .
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.TAG }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.TAG }}
|
||||
echo "Pushing latest..."
|
||||
docker push git.viaeon.com/admin/new-api:latest
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.TAG }} git.viaeon.com/admin/new-api:latest 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
|
||||
- name: Deploy via SSH
|
||||
if: success()
|
||||
run: |
|
||||
if [ -z "${{ secrets.DEPLOY_SSH_HOST }}" ]; then
|
||||
echo "DEPLOY_SSH_HOST not set, skip deploy"
|
||||
exit 0
|
||||
fi
|
||||
apk add --no-cache sshpass 2>/dev/null || apt-get update && apt-get install -y sshpass 2>/dev/null || true
|
||||
sshpass -p "${{ secrets.DEPLOY_SSH_PASS }}" ssh -o StrictHostKeyChecking=no -p ${{ secrets.DEPLOY_SSH_PORT || 22 }} ${{ secrets.DEPLOY_SSH_USER }}@${{ secrets.DEPLOY_SSH_HOST }} "cd ${{ secrets.DEPLOY_DIR || '/opt/new-api' }} && docker compose pull && docker compose up -d"
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Docker Build (alpha)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- alpha
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Alpha Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Determine alpha version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="alpha-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||
echo "$VERSION" > VERSION
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin 2>&1
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building alpha image..."
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.VERSION }} \
|
||||
-t git.viaeon.com/admin/new-api:alpha . 2>&1
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.VERSION }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.VERSION }}
|
||||
echo "Pushing alpha..."
|
||||
docker push git.viaeon.com/admin/new-api:alpha
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.VERSION }} git.viaeon.com/admin/new-api:alpha 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Docker Build (nightly)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- nightly
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push Nightly Docker Image
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
if ! command -v docker &> /dev/null; then
|
||||
if command -v apk &> /dev/null; then
|
||||
apk add --no-cache docker-cli
|
||||
elif command -v apt-get &> /dev/null; then
|
||||
apt-get update && apt-get install -y docker.io
|
||||
else
|
||||
curl -fsSL https://download.docker.com/linux/static/stable/x86_64/docker-24.0.7.tgz | tar xz -C /tmp
|
||||
mv /tmp/docker/docker /usr/local/bin/
|
||||
chmod +x /usr/local/bin/docker
|
||||
fi
|
||||
fi
|
||||
docker --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Determine nightly version
|
||||
id: version
|
||||
run: |
|
||||
VERSION="nightly-$(date +'%Y%m%d')-$(git rev-parse --short HEAD)"
|
||||
echo "$VERSION" > VERSION
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
run: |
|
||||
echo "${{ secrets.PACKAGES_TOKEN }}" | docker login git.viaeon.com -u "${{ github.actor }}" --password-stdin 2>&1
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
echo "Building nightly image..."
|
||||
docker build \
|
||||
--label "org.opencontainers.image.source=https://git.viaeon.com/admin/new-api" \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
-t git.viaeon.com/admin/new-api:${{ env.VERSION }} \
|
||||
-t git.viaeon.com/admin/new-api:nightly . 2>&1
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
echo "Pushing ${{ env.VERSION }}..."
|
||||
docker push git.viaeon.com/admin/new-api:${{ env.VERSION }}
|
||||
echo "Pushing nightly..."
|
||||
docker push git.viaeon.com/admin/new-api:nightly
|
||||
|
||||
- name: Cleanup Docker
|
||||
if: always()
|
||||
run: |
|
||||
echo "Removing local images..."
|
||||
docker rmi git.viaeon.com/admin/new-api:${{ env.VERSION }} git.viaeon.com/admin/new-api:nightly 2>/dev/null || true
|
||||
echo "Pruning unused Docker resources..."
|
||||
docker system prune -af --volumes 2>/dev/null || true
|
||||
echo "Docker disk usage:"
|
||||
docker system df
|
||||
@@ -0,0 +1,82 @@
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
name: PR Quality Check
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check PR description
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: admin/new-api
|
||||
GITEA_URL: https://git.viaeon.com
|
||||
run: |
|
||||
# 获取 PR 信息
|
||||
PR_INFO=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
|
||||
PR_BODY=$(echo "$PR_INFO" | jq -r '.body // empty')
|
||||
PR_TITLE=$(echo "$PR_INFO" | jq -r '.title // empty')
|
||||
PR_USER=$(echo "$PR_INFO" | jq -r '.user.login // empty')
|
||||
|
||||
FAILED=0
|
||||
REASONS=""
|
||||
|
||||
# 检查 PR 描述是否为空
|
||||
if [ -z "$PR_BODY" ] || [ "$PR_BODY" = "null" ]; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR description is empty\n"
|
||||
fi
|
||||
|
||||
# 检查 PR 标题是否为空
|
||||
if [ -z "$PR_TITLE" ] || [ "$PR_TITLE" = "null" ]; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR title is empty\n"
|
||||
fi
|
||||
|
||||
# 检查是否包含纯 AI 生成标记
|
||||
if echo "$PR_BODY" | grep -qi "Generated with Claude Code"; then
|
||||
FAILED=1
|
||||
REASONS="${REASONS}- PR appears to be purely AI-generated without meaningful human involvement\n"
|
||||
fi
|
||||
|
||||
if [ "$FAILED" -eq 1 ]; then
|
||||
echo "PR check failed:"
|
||||
echo -e "$REASONS"
|
||||
|
||||
# 添加标签
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/labels" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"labels": ["pr-check-failed"]}'
|
||||
|
||||
# 添加评论
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "感谢您的提交。由于该 PR 未遵循我们的贡献模板,且被识别为缺乏人工参与的纯 AI 生成内容,我们将先予以关闭。我们更欢迎经过人工审核、验证并带有个人思考的贡献。如果您认为这其中存在误解,请回复告知。"}'
|
||||
|
||||
# 关闭 PR
|
||||
curl -s -X PATCH \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/pulls/${PR_NUMBER}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state": "closed"}'
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PR check passed!"
|
||||
@@ -0,0 +1,161 @@
|
||||
name: Release (Linux)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- '!*-alpha*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag name to build (e.g., v0.10.8)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
name: Linux Release
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
export PATH="/toolcache/bin:$PATH"
|
||||
# Install Go
|
||||
if ! command -v go &> /dev/null; then
|
||||
curl -fsSL https://go.dev/dl/go1.25.1.linux-amd64.tar.gz | tar -C /usr/local -xzf -
|
||||
echo "export PATH=\$PATH:/usr/local/go/bin" >> ~/.bashrc
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
fi
|
||||
go version
|
||||
# Install Bun
|
||||
if ! command -v bun &> /dev/null; then
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
fi
|
||||
bun --version
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Determine Version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
VERSION=$(git describe --tags 2>/dev/null || echo "$TAG")
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "Building version: $VERSION"
|
||||
|
||||
- name: Build Frontend (default)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:/usr/local/go/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
export PATH="$HOME/.bun/bin:/usr/local/go/bin:$PATH"
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
|
||||
- name: Build Backend (amd64)
|
||||
run: |
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
go mod download
|
||||
go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-$VERSION
|
||||
|
||||
- name: Build Backend (arm64)
|
||||
run: |
|
||||
export PATH="/usr/local/go/bin:$PATH"
|
||||
sudo apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION
|
||||
|
||||
- name: Generate checksums
|
||||
run: sha256sum new-api-* > checksums-linux.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-build
|
||||
path: |
|
||||
new-api-*
|
||||
checksums-linux.txt
|
||||
|
||||
release:
|
||||
name: Create Gitea Release
|
||||
needs: [build-linux]
|
||||
runs-on: act-runner-4c6g
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine Version
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.tag }}" ]; then
|
||||
TAG="${{ github.event.inputs.tag }}"
|
||||
else
|
||||
TAG=${GITHUB_REF#refs/tags/}
|
||||
fi
|
||||
echo "TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
run: |
|
||||
# 使用 Gitea API 创建 Release
|
||||
TAG="${{ env.TAG }}"
|
||||
REPO="admin/new-api"
|
||||
GITEA_URL="https://git.viaeon.com"
|
||||
|
||||
# 创建 Release
|
||||
RELEASE_ID=$(curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG}\",
|
||||
\"name\": \"${TAG}\",
|
||||
\"body\": \"Release ${TAG}\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" | jq -r '.id')
|
||||
|
||||
echo "Created release ID: ${RELEASE_ID}"
|
||||
|
||||
# 上传附件
|
||||
find artifacts -type f | while read file; do
|
||||
echo "Uploading: ${file}"
|
||||
curl -s -X POST \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-F "attachment=@${file}" \
|
||||
-F "name=$(basename ${file})"
|
||||
done
|
||||
|
||||
echo "Release ${TAG} created successfully!"
|
||||
+1
-1
@@ -9,4 +9,4 @@ community_bridge: # Replace with a single Community Bridge project-name e.g., cl
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -11,6 +11,8 @@ assignees: ''
|
||||
|
||||
- 文档:https://docs.newapi.ai/
|
||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
|
||||
- 不接受 coding plan、逆向渠道等技术支持类 issue。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||
|
||||
**您当前的 newapi 版本**
|
||||
@@ -20,13 +22,18 @@ assignees: ''
|
||||
**提交确认**
|
||||
|
||||
[//]: # (方框内删除已有的空格,填 x 号)
|
||||
+ [ ] 我已确认目前没有类似 issue
|
||||
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,尤其是常见问题部分
|
||||
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
|
||||
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
|
||||
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue),确认目前没有类似 issue。
|
||||
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题。
|
||||
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。
|
||||
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。
|
||||
|
||||
**问题描述**
|
||||
|
||||
请尽可能说明问题现象、影响范围,以及你判断它是程序问题而不是上游行为或使用问题的依据。
|
||||
|
||||
- 转发问题请尽可能说明渠道类型、转换格式、上游原生支持依据和服务端日志。
|
||||
- 计费问题请尽可能附请求返回的 `usage` 示例。
|
||||
|
||||
**复现步骤**
|
||||
|
||||
**预期结果**
|
||||
|
||||
@@ -11,6 +11,8 @@ assignees: ''
|
||||
|
||||
- Docs: https://docs.newapi.ai/
|
||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
||||
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
|
||||
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
|
||||
|
||||
**Your current newapi version**
|
||||
@@ -20,13 +22,18 @@ Please fill this in, for example: `v1.0.0`
|
||||
**Submission Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues
|
||||
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, especially the FAQ section
|
||||
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
|
||||
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
|
||||
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
|
||||
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question.
|
||||
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
|
||||
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
|
||||
|
||||
**Issue Description**
|
||||
|
||||
Describe the symptom, impact scope, and why you believe this is an application issue rather than upstream behavior or a usage question with as much detail as possible.
|
||||
|
||||
- For forwarding issues, include the channel type, conversion format, upstream native-support evidence, and server logs when possible.
|
||||
- For billing issues, include an example of the returned `usage` when possible.
|
||||
|
||||
**Steps to Reproduce**
|
||||
|
||||
**Expected Result**
|
||||
|
||||
@@ -11,6 +11,8 @@ assignees: ''
|
||||
|
||||
- 文档:https://docs.newapi.ai/
|
||||
- 使用问题先看或先问:https://deepwiki.com/QuantumNous/new-api
|
||||
- 开启透传后的转发相关反馈不接受 issue;透传模式会直接转发请求,请自行确认上游行为。
|
||||
- 不接受 coding plan、逆向渠道等技术支持类 issue。
|
||||
- 警告:删除本模板、删除小节标题或随意清空内容的 issue,可能会被直接关闭;重复恶意提交者可能会被 block。
|
||||
|
||||
**您当前的 newapi 版本**
|
||||
@@ -20,10 +22,10 @@ assignees: ''
|
||||
**提交确认**
|
||||
|
||||
[//]: # (方框内删除已有的空格,填 x 号)
|
||||
+ [ ] 我已确认目前没有类似 issue
|
||||
+ [ ] 我已完整查看过文档 https://docs.newapi.ai/ 和项目 README,已确定现有版本无法满足需求
|
||||
+ [ ] 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写
|
||||
+ [ ] 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭
|
||||
- [ ] **非重复 issue:** 我已搜索现有 [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue),确认目前没有类似 issue。
|
||||
- [ ] **提交前必读:** 我已完整阅读上方“提交前必读”,并已查看文档 https://docs.newapi.ai/、项目 README 且向 AI 提问,确认这不是使用、配置或接入类问题,且现有版本无法满足需求。
|
||||
- [ ] **模板完整:** 我未删除此模板中的任何引导内容或小节标题,并会按要求完整填写。
|
||||
- [ ] **维护成本:** 我理解项目维护者精力有限,不遵循模板要求的 issue 可能会被无视或直接关闭。
|
||||
|
||||
**功能描述**
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ assignees: ''
|
||||
|
||||
- Docs: https://docs.newapi.ai/
|
||||
- Usage questions first: https://deepwiki.com/QuantumNous/new-api
|
||||
- Issues about forwarding behavior after enabling pass-through mode are not accepted; pass-through mode forwards requests directly, so please verify upstream behavior yourself.
|
||||
- Technical support requests such as coding plans or reverse-engineering channels are not accepted as issues.
|
||||
- Warning: issues with this template removed, section headings deleted, or content cleared may be closed directly. Repeated abusive submissions may result in a block.
|
||||
|
||||
**Your current newapi version**
|
||||
@@ -20,10 +22,10 @@ Please fill this in, for example: `v1.0.0`
|
||||
**Submission Checks**
|
||||
|
||||
[//]: # (Remove the space in the box and fill with an x)
|
||||
+ [ ] I have confirmed there are no similar issues
|
||||
+ [ ] I have thoroughly read the docs at https://docs.newapi.ai/ and the project README, and confirmed the current version cannot meet my needs
|
||||
+ [ ] I have not removed any guidance or section headings from this template and will complete it as requested
|
||||
+ [ ] I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly
|
||||
- [ ] **Non-duplicate issue:** I have searched existing [Issues](https://github.com/QuantumNous/new-api/issues?q=is%3Aissue) and confirmed there are no similar issues.
|
||||
- [ ] **Read this first:** I have fully read the section above, reviewed the docs at https://docs.newapi.ai/ and the project README, and asked AI first, confirming this is not a usage, configuration, or integration question, and that the current version cannot meet my needs.
|
||||
- [ ] **Template intact:** I have not removed any guidance or section headings from this template and will complete it as requested.
|
||||
- [ ] **Maintainer time:** I understand that maintainers have limited time and issues that do not follow this template may be ignored or closed directly.
|
||||
|
||||
**Feature Description**
|
||||
|
||||
|
||||
@@ -33,16 +33,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -91,16 +93,18 @@ jobs:
|
||||
CI: ""
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
@@ -146,16 +150,18 @@ jobs:
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/default
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd default
|
||||
DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Build Frontend (classic)
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web/classic
|
||||
bun install
|
||||
cd web
|
||||
bun install --frozen-lockfile
|
||||
cd classic
|
||||
VITE_REACT_APP_VERSION=$VERSION bun run build
|
||||
cd ../..
|
||||
- name: Set up Go
|
||||
|
||||
+3
-1
@@ -7,9 +7,10 @@ upload
|
||||
*.db
|
||||
build
|
||||
*.db-journal
|
||||
logs
|
||||
/logs
|
||||
web/default/dist
|
||||
web/classic/dist
|
||||
web/daisy/dist
|
||||
web/node_modules
|
||||
web/dist
|
||||
.env
|
||||
@@ -35,3 +36,4 @@ data/
|
||||
.test
|
||||
token_estimator_test.go
|
||||
skills-lock.json
|
||||
.playwright-mcp
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# ModelsToken 管理平台 - 产品需求文档 (PRD)
|
||||
|
||||
## 1. 产品概述
|
||||
|
||||
ModelsToken 是一个 AI API 管理与分发平台,为开发者和企业提供统一的 AI 模型接入、密钥管理、用量计费、渠道代理等一站式服务。新前端将采用 React + DaisyUI 5 + TypeScript 构建,替换现有的 Default/Classic 双前端,同时新增本地文档管理功能。
|
||||
|
||||
- 目标用户:AI 应用开发者、企业运维人员、API 服务管理者
|
||||
- 核心价值:简化 AI API 的管理复杂度,提供直观的操作界面和完整的文档支持
|
||||
|
||||
## 2. 核心功能
|
||||
|
||||
### 2.1 用户角色
|
||||
|
||||
| 角色 | 注册方式 | 核心权限 |
|
||||
|------|----------|----------|
|
||||
| 普通用户 | 用户名/邮箱/OAuth | 密钥管理、充值、订阅、日志查看、文档访问 |
|
||||
| 管理员 | 由超级管理员指定 | 渠道管理、用户管理、兑换码、模型管理、订阅管理 |
|
||||
| 超级管理员 | 系统初始化 | 全部权限 + 系统设置 |
|
||||
|
||||
### 2.2 功能模块
|
||||
|
||||
#### 公共页面(无需登录)
|
||||
1. **首页**:Hero 区域、特性展示、快速入门指引
|
||||
2. **登录页**:用户名/密码、OAuth 登录(GitHub/Discord/OIDC/LinuxDO/微信/Telegram/自定义)
|
||||
3. **注册页**:注册表单 + Turnstile 人机验证
|
||||
4. **忘记密码**:邮箱重置链接
|
||||
5. **模型定价**:模型价格列表、搜索筛选
|
||||
6. **关于页面**:项目信息、版本、许可证
|
||||
7. **用户协议/隐私政策**
|
||||
8. **初始化向导**:首次部署配置
|
||||
|
||||
#### 用户功能(需登录)
|
||||
1. **仪表盘**:额度概览、使用趋势图、API 信息面板、公告、FAQ
|
||||
2. **API 密钥管理**:创建/编辑/删除/批量操作、额度限制、模型限制、IP 限制
|
||||
3. **钱包/充值**:余额查看、兑换码充值、在线支付(易支付/Stripe/Creem/Waffo)、签到
|
||||
4. **订阅管理**:查看计划、购买订阅、当前订阅状态
|
||||
5. **使用日志**:请求日志搜索/筛选、MJ 日志、任务日志、统计图表
|
||||
6. **个人设置**:资料编辑、2FA 设置、Passkey 管理、OAuth 绑定、语言切换
|
||||
7. **Playground**:API 在线调试、Chat Completions 测试
|
||||
8. **文档中心**(新增):本地文档管理、分类浏览、搜索、Markdown 渲染
|
||||
|
||||
#### 管理员功能
|
||||
1. **渠道管理**:CRUD、测试、余额更新、标签管理、批量操作、多密钥、Codex OAuth、Ollama 管理
|
||||
2. **用户管理**:列表/搜索/创建/编辑/升降级/启禁/额度调整
|
||||
3. **兑换码管理**:CRUD、批量删除无效码
|
||||
4. **模型管理**:模型元数据 CRUD、上游同步、缺失模型检测
|
||||
5. **供应商管理**:CRUD
|
||||
6. **订阅管理**:计划 CRUD、用户订阅管理
|
||||
7. **部署管理**:io.net 部署 CRUD、容器管理、日志
|
||||
|
||||
#### 超级管理员 - 系统设置
|
||||
1. **站点设置**:名称/Logo/页脚/公告/首页内容/服务器地址
|
||||
2. **认证设置**:注册/登录开关、OAuth 配置、Turnstile、Passkey、自定义 OAuth
|
||||
3. **计费设置**:额度/倍率/支付配置/签到/分组倍率
|
||||
4. **内容设置**:公告/FAQ/Uptime Kuma/聊天/绘图/Midjourney
|
||||
5. **模型设置**:透传/思维模型/Gemini/Claude 配置
|
||||
6. **运维设置**:重试/自动禁用/SMTP/性能监控/日志
|
||||
7. **安全设置**:速率限制/敏感词/SSRF 防护/IP 过滤
|
||||
|
||||
### 2.3 新增功能 - 本地文档管理
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 文档分类 | 支持多级分类树,管理员可创建/编辑/删除分类 |
|
||||
| 文档 CRUD | 管理员创建/编辑/删除文档,支持 Markdown 编辑器 |
|
||||
| 文档浏览 | 用户按分类浏览文档,支持搜索 |
|
||||
| 文档搜索 | 全文搜索文档标题和内容 |
|
||||
| 文档版本 | 文档更新历史记录 |
|
||||
| 权限控制 | 可设置文档为公开/登录可见/管理员可见 |
|
||||
|
||||
## 3. 核心流程
|
||||
|
||||
### 3.1 用户认证流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"访问平台" --> "已登录?"
|
||||
"已登录?" -->|"是"| "仪表盘"
|
||||
"已登录?" -->|"否"| "登录页"
|
||||
"登录页" --> "输入凭证"
|
||||
"输入凭证" --> "需要2FA?"
|
||||
"需要2FA?" -->|"是"| "输入2FA码"
|
||||
"需要2FA?" -->|"否"| "验证成功"
|
||||
"输入2FA码" --> "验证成功"
|
||||
"验证成功" --> "仪表盘"
|
||||
"登录页" --> "OAuth登录"
|
||||
"OAuth登录" --> "OAuth回调"
|
||||
"OAuth回调" --> "已绑定账号?"
|
||||
"已绑定账号?" -->|"是"| "仪表盘"
|
||||
"已绑定账号?" -->|"否"| "绑定/注册"
|
||||
```
|
||||
|
||||
### 3.2 API 调用流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"创建API密钥" --> "配置密钥参数"
|
||||
"配置密钥参数" --> "使用密钥调用API"
|
||||
"使用密钥调用API" --> "平台路由到渠道"
|
||||
"平台路由到渠道" --> "返回结果"
|
||||
"返回结果" --> "记录日志"
|
||||
"记录日志" --> "扣除额度"
|
||||
```
|
||||
|
||||
### 3.3 文档管理流程(新增)
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
"管理员创建分类" --> "创建文档"
|
||||
"创建文档" --> "Markdown编辑"
|
||||
"Markdown编辑" --> "设置可见性"
|
||||
"设置可见性" --> "发布文档"
|
||||
"发布文档" --> "用户浏览/搜索"
|
||||
```
|
||||
|
||||
## 4. 用户界面设计
|
||||
|
||||
### 4.1 设计风格
|
||||
|
||||
- **主色调**:深蓝 (#1e293b) + 亮蓝 (#3b82f6) 渐变,搭配 DaisyUI 的 `business` 主题
|
||||
- **辅助色**:翡翠绿 (#10b981) 用于成功/在线状态,琥珀色 (#f59e0b) 用于警告
|
||||
- **按钮风格**:DaisyUI 默认圆角按钮,主要操作用 `btn-primary`,危险操作用 `btn-error`
|
||||
- **字体**:JetBrains Mono(代码/密钥)+ Noto Sans SC(中文正文)
|
||||
- **布局风格**:左侧固定导航栏 + 顶部状态栏 + 主内容区,响应式折叠
|
||||
- **图标**:Lucide React 图标库
|
||||
- **动效**:DaisyUI 内置动画 + 页面切换淡入
|
||||
|
||||
### 4.2 页面设计概览
|
||||
|
||||
| 页面 | 模块 | UI 元素 |
|
||||
|------|------|---------|
|
||||
| 首页 | Hero | 渐变背景、特性卡片、快速开始按钮 |
|
||||
| 登录 | 表单 | 居中卡片、OAuth 按钮组、Turnstile |
|
||||
| 仪表盘 | 统计卡片 | 4 列额度卡片、折线图、公告栏、API 信息 |
|
||||
| 密钥管理 | 数据表 | 搜索栏、筛选器、表格、批量操作栏 |
|
||||
| 渠道管理 | 数据表+表单 | 标签筛选、测试按钮、多密钥管理抽屉 |
|
||||
| 系统设置 | 标签页 | 7 大分类侧边导航、表单分组、开关/输入框 |
|
||||
| 文档中心 | 侧边树+内容 | 分类树导航、Markdown 渲染、搜索框、面包屑 |
|
||||
| Playground | 分栏 | 左侧参数面板、右侧响应面板、模型选择器 |
|
||||
|
||||
### 4.3 响应式设计
|
||||
|
||||
- 桌面优先(1280px+)
|
||||
- 平板适配(768px-1279px):侧边栏折叠为抽屉
|
||||
- 移动端适配(<768px):单列布局,表格改为卡片列表
|
||||
|
||||
### 4.4 布局结构
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 顶部导航栏 (Navbar) │
|
||||
│ Logo | 搜索 | 通知 | 用户菜单 | 主题切换 │
|
||||
├──────┬───────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ 侧边 │ 主内容区 │
|
||||
│ 导航 │ │
|
||||
│ 栏 │ ┌─────────────────────────────────┐ │
|
||||
│ │ │ 面包屑 + 页面标题 + 操作按钮 │ │
|
||||
│ 仪表盘│ ├─────────────────────────────────┤ │
|
||||
│ 密钥 │ │ │ │
|
||||
│ 渠道 │ │ 页面内容 │ │
|
||||
│ 用户 │ │ │ │
|
||||
│ 日志 │ │ │ │
|
||||
│ 钱包 │ └─────────────────────────────────┘ │
|
||||
│ 订阅 │ │
|
||||
│ 文档 │ │
|
||||
│ 设置 │ │
|
||||
│ │ │
|
||||
└──────┴───────────────────────────────────────┘
|
||||
```
|
||||
@@ -0,0 +1,459 @@
|
||||
# ModelsToken 管理平台 - 技术架构文档
|
||||
|
||||
## 1. 架构设计
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "前端 (React + DaisyUI 5)"
|
||||
A["React 18"] --> B["React Router v6"]
|
||||
B --> C["页面组件"]
|
||||
C --> D["DaisyUI 5 组件"]
|
||||
D --> E["Tailwind CSS 4"]
|
||||
A --> F["Zustand 状态管理"]
|
||||
A --> G["React Query 数据请求"]
|
||||
A --> H["i18next 国际化"]
|
||||
A --> I["React Markdown 渲染"]
|
||||
end
|
||||
subgraph "后端 (Go + Gin)"
|
||||
J["Gin HTTP Server"]
|
||||
J --> K["API 路由"]
|
||||
J --> L["Relay 代理"]
|
||||
K --> M["控制器"]
|
||||
M --> N["模型层"]
|
||||
N --> O["数据库 (SQLite/MySQL/PostgreSQL)"]
|
||||
end
|
||||
C -->|"Axios HTTP"| K
|
||||
```
|
||||
|
||||
## 2. 技术说明
|
||||
|
||||
- **前端框架**:React 18 + TypeScript
|
||||
- **UI 库**:DaisyUI 5 + Tailwind CSS 4
|
||||
- **构建工具**:Vite 6
|
||||
- **路由**:React Router v6(懒加载)
|
||||
- **状态管理**:Zustand(轻量级,替代 Redux)
|
||||
- **数据请求**:TanStack React Query v5 + Axios
|
||||
- **国际化**:i18next + react-i18next
|
||||
- **图表**:Recharts
|
||||
- **Markdown**:react-markdown + remark-gfm + rehype-highlight
|
||||
- **图标**:Lucide React
|
||||
- **代码高亮**:highlight.js
|
||||
- **表单验证**:React Hook Form + Zod
|
||||
- **通知**:react-hot-toast
|
||||
- **项目目录**:`web/daisy/`
|
||||
|
||||
## 3. 路由定义
|
||||
|
||||
### 3.1 公共路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/` | 首页 |
|
||||
| `/login` | 登录 |
|
||||
| `/register` | 注册 |
|
||||
| `/forgot-password` | 忘记密码 |
|
||||
| `/reset-password` | 密码重置确认 |
|
||||
| `/setup` | 初始化向导 |
|
||||
| `/pricing` | 模型定价 |
|
||||
| `/about` | 关于 |
|
||||
| `/user-agreement` | 用户协议 |
|
||||
| `/privacy-policy` | 隐私政策 |
|
||||
| `/oauth/callback/:provider` | OAuth 回调 |
|
||||
|
||||
### 3.2 认证后路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/dashboard` | 仪表盘 |
|
||||
| `/tokens` | API 密钥管理 |
|
||||
| `/wallet` | 钱包/充值 |
|
||||
| `/subscriptions` | 订阅管理 |
|
||||
| `/logs` | 使用日志 |
|
||||
| `/logs/midjourney` | MJ 日志 |
|
||||
| `/logs/tasks` | 任务日志 |
|
||||
| `/profile` | 个人设置 |
|
||||
| `/playground` | Playground |
|
||||
| `/docs` | 文档中心(新增) |
|
||||
| `/docs/:slug` | 文档详情(新增) |
|
||||
|
||||
### 3.3 管理员路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/admin/channels` | 渠道管理 |
|
||||
| `/admin/users` | 用户管理 |
|
||||
| `/admin/redemptions` | 兑换码管理 |
|
||||
| `/admin/models` | 模型管理 |
|
||||
| `/admin/vendors` | 供应商管理 |
|
||||
| `/admin/deployments` | 部署管理 |
|
||||
| `/admin/subscriptions` | 订阅计划管理 |
|
||||
|
||||
### 3.4 超级管理员路由
|
||||
|
||||
| 路由 | 用途 |
|
||||
|------|------|
|
||||
| `/settings/site` | 站点设置 |
|
||||
| `/settings/auth` | 认证设置 |
|
||||
| `/settings/billing` | 计费设置 |
|
||||
| `/settings/content` | 内容设置 |
|
||||
| `/settings/models` | 模型设置 |
|
||||
| `/settings/operations` | 运维设置 |
|
||||
| `/settings/security` | 安全设置 |
|
||||
| `/settings/docs` | 文档管理(新增) |
|
||||
|
||||
## 4. API 定义
|
||||
|
||||
### 4.1 核心类型
|
||||
|
||||
```typescript
|
||||
// 用户
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: number; // 1=user, 10=admin, 100=root
|
||||
status: number;
|
||||
quota: number;
|
||||
used_quota: number;
|
||||
request_count: number;
|
||||
group: string;
|
||||
aff_code: string;
|
||||
inviter_id: number;
|
||||
language: string;
|
||||
access_token: string;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 渠道
|
||||
interface Channel {
|
||||
id: number;
|
||||
type: number;
|
||||
key: string;
|
||||
openai_organization?: string;
|
||||
base_url: string;
|
||||
models: string;
|
||||
model_mapping?: string;
|
||||
group: string;
|
||||
groups: string[];
|
||||
name: string;
|
||||
priority: number;
|
||||
weight: number;
|
||||
status: number;
|
||||
tag?: string;
|
||||
setting?: string;
|
||||
test_time: number;
|
||||
response_time: number;
|
||||
balance: number;
|
||||
balance_updated_time: number;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 令牌
|
||||
interface Token {
|
||||
id: number;
|
||||
user_id: number;
|
||||
key: string;
|
||||
status: number;
|
||||
name: string;
|
||||
created_time: number;
|
||||
accessed_time: number;
|
||||
expired_time: number;
|
||||
remain_quota: number;
|
||||
unlimited_quota: boolean;
|
||||
used_quota: number;
|
||||
models: string;
|
||||
subnet: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// 日志
|
||||
interface Log {
|
||||
id: number;
|
||||
user_id: number;
|
||||
created_at: number;
|
||||
type: number;
|
||||
content: string;
|
||||
username: string;
|
||||
token_name: string;
|
||||
model_name: string;
|
||||
quota: number;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
channel_id: number;
|
||||
token_id: number;
|
||||
group: string;
|
||||
request_id: string;
|
||||
ip: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
// 订阅计划
|
||||
interface SubscriptionPlan {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
duration_days: number;
|
||||
quota: number;
|
||||
models: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
created_time: number;
|
||||
}
|
||||
|
||||
// 文档(新增)
|
||||
interface Document {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string; // Markdown
|
||||
category_id: number;
|
||||
category?: DocumentCategory;
|
||||
visibility: 'public' | 'auth' | 'admin';
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
author_id: number;
|
||||
author?: User;
|
||||
versions?: DocumentVersion[];
|
||||
}
|
||||
|
||||
interface DocumentCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
children?: DocumentCategory[];
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface DocumentVersion {
|
||||
id: number;
|
||||
document_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
author_id: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 新增文档管理 API
|
||||
|
||||
| 端点 | 方法 | 权限 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/api/docs/categories` | GET | 公开 | 获取分类树 |
|
||||
| `/api/docs/categories` | POST | Admin | 创建分类 |
|
||||
| `/api/docs/categories/:id` | PUT | Admin | 更新分类 |
|
||||
| `/api/docs/categories/:id` | DELETE | Admin | 删除分类 |
|
||||
| `/api/docs/` | GET | 按可见性 | 文档列表(支持搜索) |
|
||||
| `/api/docs/:slug` | GET | 按可见性 | 获取文档详情 |
|
||||
| `/api/docs/` | POST | Admin | 创建文档 |
|
||||
| `/api/docs/:id` | PUT | Admin | 更新文档 |
|
||||
| `/api/docs/:id` | DELETE | Admin | 删除文档 |
|
||||
| `/api/docs/:id/versions` | GET | Admin | 文档版本历史 |
|
||||
|
||||
## 5. 项目目录结构
|
||||
|
||||
```
|
||||
web/daisy/
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── vite.config.ts
|
||||
├── tailwind.config.ts
|
||||
├── public/
|
||||
│ └── manifest.json
|
||||
└── src/
|
||||
├── main.tsx # 入口
|
||||
├── App.tsx # 根组件 + 路由
|
||||
├── vite-env.d.ts
|
||||
├── api/ # API 请求层
|
||||
│ ├── client.ts # Axios 实例 + 拦截器
|
||||
│ ├── auth.ts # 认证 API
|
||||
│ ├── channel.ts # 渠道 API
|
||||
│ ├── token.ts # 令牌 API
|
||||
│ ├── user.ts # 用户 API
|
||||
│ ├── log.ts # 日志 API
|
||||
│ ├── subscription.ts # 订阅 API
|
||||
│ ├── redemption.ts # 兑换码 API
|
||||
│ ├── model.ts # 模型 API
|
||||
│ ├── vendor.ts # 供应商 API
|
||||
│ ├── deployment.ts # 部署 API
|
||||
│ ├── option.ts # 系统设置 API
|
||||
│ ├── payment.ts # 支付 API
|
||||
│ └── doc.ts # 文档 API(新增)
|
||||
├── stores/ # Zustand 状态
|
||||
│ ├── auth.ts # 认证状态
|
||||
│ └── ui.ts # UI 状态(侧边栏/主题)
|
||||
├── hooks/ # 自定义 Hooks
|
||||
│ ├── useAuth.ts
|
||||
│ ├── usePermission.ts
|
||||
│ └── useQuota.ts
|
||||
├── components/ # 通用组件
|
||||
│ ├── layout/
|
||||
│ │ ├── AppLayout.tsx # 主布局
|
||||
│ │ ├── Sidebar.tsx # 侧边导航
|
||||
│ │ ├── Navbar.tsx # 顶部导航
|
||||
│ │ └── Breadcrumb.tsx # 面包屑
|
||||
│ ├── common/
|
||||
│ │ ├── QuotaDisplay.tsx # 额度显示
|
||||
│ │ ├── ModelBadge.tsx # 模型标签
|
||||
│ │ ├── StatusBadge.tsx # 状态标签
|
||||
│ │ ├── SearchInput.tsx # 搜索框
|
||||
│ │ ├── DataTable.tsx # 数据表格
|
||||
│ │ ├── ConfirmDialog.tsx # 确认对话框
|
||||
│ │ └── LoadingSpinner.tsx # 加载动画
|
||||
│ └── charts/
|
||||
│ ├── QuotaChart.tsx # 额度趋势图
|
||||
│ └── StatsChart.tsx # 统计图表
|
||||
├── pages/ # 页面组件
|
||||
│ ├── public/
|
||||
│ │ ├── Home.tsx
|
||||
│ │ ├── Login.tsx
|
||||
│ │ ├── Register.tsx
|
||||
│ │ ├── ForgotPassword.tsx
|
||||
│ │ ├── Pricing.tsx
|
||||
│ │ ├── About.tsx
|
||||
│ │ └── Setup.tsx
|
||||
│ ├── dashboard/
|
||||
│ │ └── Dashboard.tsx
|
||||
│ ├── tokens/
|
||||
│ │ ├── TokenList.tsx
|
||||
│ │ └── TokenForm.tsx
|
||||
│ ├── channels/
|
||||
│ │ ├── ChannelList.tsx
|
||||
│ │ └── ChannelForm.tsx
|
||||
│ ├── users/
|
||||
│ │ ├── UserList.tsx
|
||||
│ │ └── UserForm.tsx
|
||||
│ ├── logs/
|
||||
│ │ ├── LogList.tsx
|
||||
│ │ ├── MidjourneyLog.tsx
|
||||
│ │ └── TaskLog.tsx
|
||||
│ ├── wallet/
|
||||
│ │ └── Wallet.tsx
|
||||
│ ├── subscriptions/
|
||||
│ │ ├── PlanList.tsx
|
||||
│ │ └── MySubscription.tsx
|
||||
│ ├── redemptions/
|
||||
│ │ └── RedemptionList.tsx
|
||||
│ ├── models/
|
||||
│ │ └── ModelList.tsx
|
||||
│ ├── vendors/
|
||||
│ │ └── VendorList.tsx
|
||||
│ ├── deployments/
|
||||
│ │ └── DeploymentList.tsx
|
||||
│ ├── playground/
|
||||
│ │ └── Playground.tsx
|
||||
│ ├── profile/
|
||||
│ │ └── Profile.tsx
|
||||
│ ├── docs/ # 文档中心(新增)
|
||||
│ │ ├── DocCenter.tsx # 文档浏览主页
|
||||
│ │ ├── DocViewer.tsx # 文档阅读页
|
||||
│ │ ├── DocEditor.tsx # 文档编辑页(管理员)
|
||||
│ │ └── DocCategoryManager.tsx # 分类管理(管理员)
|
||||
│ └── settings/
|
||||
│ ├── SiteSettings.tsx
|
||||
│ ├── AuthSettings.tsx
|
||||
│ ├── BillingSettings.tsx
|
||||
│ ├── ContentSettings.tsx
|
||||
│ ├── ModelSettings.tsx
|
||||
│ ├── OperationsSettings.tsx
|
||||
│ ├── SecuritySettings.tsx
|
||||
│ └── DocSettings.tsx # 文档设置(新增)
|
||||
├── i18n/ # 国际化
|
||||
│ ├── index.ts
|
||||
│ └── locales/
|
||||
│ ├── en.json
|
||||
│ └── zh.json
|
||||
├── lib/ # 工具函数
|
||||
│ ├── constants.ts
|
||||
│ ├── utils.ts
|
||||
│ ├── quota.ts
|
||||
│ └── channel-types.ts
|
||||
└── types/ # TypeScript 类型
|
||||
├── api.ts
|
||||
├── channel.ts
|
||||
├── token.ts
|
||||
├── user.ts
|
||||
├── log.ts
|
||||
├── subscription.ts
|
||||
├── doc.ts
|
||||
└── option.ts
|
||||
```
|
||||
|
||||
## 6. 数据模型(新增文档管理)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
"document_categories" {
|
||||
int id PK
|
||||
string name
|
||||
string slug UK
|
||||
int parent_id FK
|
||||
int sort_order
|
||||
timestamp created_at
|
||||
}
|
||||
"documents" {
|
||||
int id PK
|
||||
string title
|
||||
string slug UK
|
||||
text content
|
||||
int category_id FK
|
||||
string visibility
|
||||
int sort_order
|
||||
int author_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
"document_versions" {
|
||||
int id PK
|
||||
int document_id FK
|
||||
text content
|
||||
int author_id FK
|
||||
timestamp created_at
|
||||
}
|
||||
"document_categories" ||--o{ "document_categories" : "parent"
|
||||
"document_categories" ||--o{ "documents" : "has"
|
||||
"documents" ||--o{ "document_versions" : "has"
|
||||
```
|
||||
|
||||
### DDL
|
||||
|
||||
```sql
|
||||
CREATE TABLE document_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
parent_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
slug VARCHAR(200) NOT NULL UNIQUE,
|
||||
content TEXT NOT NULL,
|
||||
category_id INTEGER REFERENCES document_categories(id) ON DELETE SET NULL,
|
||||
visibility VARCHAR(20) DEFAULT 'public' CHECK (visibility IN ('public', 'auth', 'admin')),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE document_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id INTEGER NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
author_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_documents_slug ON documents(slug);
|
||||
CREATE INDEX idx_documents_category ON documents(category_id);
|
||||
CREATE INDEX idx_documents_visibility ON documents(visibility);
|
||||
CREATE INDEX idx_document_versions_doc ON document_versions(document_id);
|
||||
```
|
||||
+19
-16
@@ -1,22 +1,24 @@
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/default/package.json .
|
||||
COPY web/default/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/default .
|
||||
COPY ./VERSION .
|
||||
RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/default ./default
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd default && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic
|
||||
|
||||
WORKDIR /build
|
||||
COPY web/classic/package.json .
|
||||
COPY web/classic/bun.lock .
|
||||
RUN bun install
|
||||
COPY ./web/classic .
|
||||
COPY ./VERSION .
|
||||
RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build
|
||||
WORKDIR /build/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
COPY web/default/package.json ./default/package.json
|
||||
COPY web/classic/package.json ./classic/package.json
|
||||
RUN bun install --frozen-lockfile
|
||||
COPY ./web/classic ./classic
|
||||
COPY ./VERSION /build/VERSION
|
||||
RUN cd classic && VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build
|
||||
|
||||
FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2
|
||||
ENV GO111MODULE=on CGO_ENABLED=0
|
||||
@@ -32,8 +34,8 @@ ADD go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
COPY --from=builder /build/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/dist ./web/classic/dist
|
||||
COPY --from=builder /build/web/default/dist ./web/default/dist
|
||||
COPY --from=builder-classic /build/web/classic/dist ./web/classic/dist
|
||||
RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api
|
||||
|
||||
FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a
|
||||
@@ -44,6 +46,7 @@ RUN apt-get update \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder2 /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
|
||||
@@ -30,6 +30,7 @@ RUN apt-get update \
|
||||
&& update-ca-certificates
|
||||
|
||||
COPY --from=builder /build/new-api /
|
||||
COPY LICENSE NOTICE THIRD-PARTY-LICENSES.md /licenses/
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
ENTRYPOINT ["/new-api"]
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
new-api Notices
|
||||
|
||||
new-api
|
||||
Copyright (c) modelstoken and contributors.
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0.
|
||||
See LICENSE for the full project license terms.
|
||||
|
||||
==== Additional Terms under AGPLv3 Section 7 ====
|
||||
|
||||
Pursuant to Section 7(b) of the GNU Affero General Public License version 3,
|
||||
the following reasonable legal notice and author attribution must be preserved
|
||||
by modified versions in the Appropriate Legal Notices and in any prominent
|
||||
about, legal, footer, or attribution location presented by the user interface:
|
||||
|
||||
"Frontend design and development by New API contributors."
|
||||
|
||||
Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project in a prominent about, legal, footer, or
|
||||
attribution location:
|
||||
|
||||
https://git.viaeon.com/admin/new-api
|
||||
|
||||
Modified versions must not misrepresent the origin of the software and must
|
||||
mark their changes in accordance with AGPLv3 Section 7(c).
|
||||
|
||||
==== Third-Party Notices ====
|
||||
|
||||
This product includes third-party open source software. Copyright notices and
|
||||
license terms for direct third-party dependencies are listed in
|
||||
THIRD-PARTY-LICENSES.md.
|
||||
|
||||
Apache-2.0 upstream NOTICE entries identified for direct dependencies are
|
||||
reproduced below. Preserve this file with Docker images, standalone binaries,
|
||||
frontend bundles, and Electron desktop installers distributed to users.
|
||||
|
||||
==== Apache-2.0 Notices ====
|
||||
|
||||
AWS SDK for Go
|
||||
Copyright 2015 Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
Copyright 2014-2015 Stripe, Inc.
|
||||
|
||||
smithy-go
|
||||
Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
||||
|
||||
otp
|
||||
Copyright (c) 2014, Paul Querna
|
||||
|
||||
This product includes software developed by
|
||||
Paul Querna (http://paul.querna.org/).
|
||||
|
||||
==== Electron / Chromium Notices ====
|
||||
|
||||
Desktop distributions include Electron, which embeds Chromium, Node.js, V8,
|
||||
and other third-party components. Electron and Chromium third-party license
|
||||
notices must remain available with desktop installers and installed apps.
|
||||
|
||||
==== End of Notices ====
|
||||
+15
-11
@@ -53,9 +53,10 @@
|
||||
> This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -146,6 +147,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -184,12 +188,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Payment and Billing
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Online recharge (EPay, Stripe)
|
||||
- ✅ Pay-per-use model pricing
|
||||
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||
- ✅ Flexible billing policy configuration
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
@@ -248,7 +252,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -259,7 +263,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/create-message) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://doc.newapi.pro/en/api/google-gemini-chat) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom | Supports complete call address | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
@@ -409,7 +413,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 Description du projet
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - Ce projet est uniquement destiné à des fins d'apprentissage personnel, sans garantie de stabilité ni de support technique.
|
||||
> - Les utilisateurs doivent se conformer aux [Conditions d'utilisation](https://openai.com/policies/terms-of-use) d'OpenAI et aux **lois et réglementations applicables**, et ne doivent pas l'utiliser à des fins illégales.
|
||||
> - Conformément aux [《Mesures provisoires pour la gestion des services d'intelligence artificielle générative》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), veuillez ne fournir aucun service d'IA générative non enregistré au public en Chine.
|
||||
> - Ce projet est exclusivement destiné aux scénarios de passerelle API d'IA légalement autorisés, d'authentification organisationnelle, de gestion multi-modèles, d'analyse d'utilisation, de comptabilisation des coûts et de déploiement privé.
|
||||
> - Les utilisateurs doivent obtenir légalement les clés API, comptes, services de modèles et autorisations d'interface en amont, et doivent respecter les conditions d'utilisation en amont et les lois et réglementations applicables.
|
||||
> - Les utilisateurs doivent s'assurer que leur utilisation est conforme aux conditions d'utilisation en amont et aux lois et réglementations applicables.
|
||||
> - Lors de la fourniture de services d'IA générative au public, les utilisateurs doivent se conformer aux exigences réglementaires applicables et remplir toutes les obligations d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité et d'autorisation en amont requises par leur juridiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 Après le déploiement, visitez `http://localhost:3000` pour commencer à utiliser!
|
||||
|
||||
> [!WARNING]
|
||||
> Lorsque vous exploitez ce projet en tant que service public d'IA générative ou service de revente d'API, les utilisateurs doivent d'abord remplir toutes les obligations requises en matière d'enregistrement, de licence, de sécurité du contenu, de vérification d'identité, de conservation des journaux, de fiscalité, de paiement et d'autorisation en amont.
|
||||
|
||||
📖 Pour plus de méthodes de déploiement, veuillez vous référer à [Guide de déploiement](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Tableau de bord des données | Console visuelle et analyse statistique |
|
||||
| 🔒 Gestion des permissions | Regroupement de jetons, restrictions de modèles, gestion des utilisateurs |
|
||||
|
||||
### 💰 Paiement et facturation
|
||||
### 💰 Comptabilisation et facturation des usages autorisés
|
||||
|
||||
- ✅ Recharge en ligne (EPay, Stripe)
|
||||
- ✅ Tarification des modèles de paiement à l'utilisation
|
||||
- ✅ Prise en charge de la facturation du cache (OpenAI, Azure, DeepSeek, Claude, Qwen et tous les modèles pris en charge)
|
||||
- ✅ Configuration flexible des politiques de facturation
|
||||
- ✅ Rechargement interne et allocation de quotas pour les scénarios légalement autorisés (EPay, Stripe)
|
||||
- ✅ Comptabilisation des coûts par requête, par utilisation et par hit de cache au niveau organisationnel
|
||||
- ✅ Statistiques de facturation du cache pour OpenAI, Azure, DeepSeek, Claude, Qwen et les modèles pris en charge
|
||||
- ✅ Politiques de facturation flexibles pour la gestion interne ou les clients entreprise autorisés
|
||||
|
||||
### 🔐 Autorisation et sécurité
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 Connexion par autorisation LinuxDO
|
||||
- 📱 Connexion par autorisation Telegram
|
||||
- 🔑 Authentification unifiée OIDC
|
||||
- 🔍 Requête de quota d'utilisation de clé (avec [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Requête de quota d'utilisation de clé (avec [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Fonctionnalités avancées
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Prise en charge des modèles
|
||||
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de relais](https://docs.newapi.pro/en/docs/api)
|
||||
> Pour les détails, veuillez vous référer à [Documentation de l'API - Interface de passerelle](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Type de modèle | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Format Messages | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Format Google Gemini | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | Mode ChatFlow | - |
|
||||
| 🎯 Personnalisé | Prise en charge de l'adresse d'appel complète | - |
|
||||
| 🎯 Amont personnalisé | Configuration des points d'accès amont légalement autorisés | - |
|
||||
|
||||
### 📡 Interfaces prises en charge
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Projet | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Outil de recherche de quota d'utilisation avec une clé |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | Version optimisée haute performance de New API |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 プロジェクト説明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本プロジェクトは個人学習用のみであり、安定性の保証や技術サポートは提供しません。
|
||||
> - ユーザーは、OpenAIの[利用規約](https://openai.com/policies/terms-of-use)および**法律法規**を遵守する必要があり、違法な目的で使用してはいけません。
|
||||
> - [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)の要求に従い、中国地域の公衆に未登録の生成式AI サービスを提供しないでください。
|
||||
> - 本プロジェクトは、合法的に許可された AI API ゲートウェイ、組織レベルの認証、マルチモデル管理、利用量分析、コスト管理、プライベートデプロイのシナリオのみを対象としています。
|
||||
> - ユーザーは、上流の API キー、アカウント、モデルサービス、インターフェース権限を合法的に取得し、上流のサービス利用規約および適用される法律法規を遵守する必要があります。
|
||||
> - ユーザーは、利用方法が上流のサービス利用規約および適用される法律法規に準拠していることを確認してください。
|
||||
> - 生成 AI サービスを公衆に提供する場合、ユーザーは適用される規制要件を遵守し、管轄区域で求められる届出、ライセンス、コンテンツセキュリティ、本人確認、ログ保持、税務、上流認可などのすべての義務を履行してください。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 デプロイが完了したら、`http://localhost:3000` にアクセスして使用を開始してください!
|
||||
|
||||
> [!WARNING]
|
||||
> 本プロジェクトを公衆向け生成 AI サービスまたは API 再販サービスとして運営する場合、ユーザーは届出、コンテンツセキュリティ、本人確認、ログ保持、税務、決済、上流認可などの必要なコンプライアンス義務を先に完了してください。
|
||||
|
||||
📖 その他のデプロイ方法については[デプロイガイド](https://docs.newapi.pro/ja/docs/installation)を参照してください。
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 データダッシュボード | ビジュアルコンソールと統計分析 |
|
||||
| 🔒 権限管理 | トークングループ化、モデル制限、ユーザー管理 |
|
||||
|
||||
### 💰 支払いと課金
|
||||
### 💰 認可済み利用量とコスト管理
|
||||
|
||||
- ✅ オンライン充電(EPay、Stripe)
|
||||
- ✅ モデルの従量課金
|
||||
- ✅ キャッシュ課金サポート(OpenAI、Azure、DeepSeek、Claude、Qwenなどすべてのサポートされているモデル)
|
||||
- ✅ 柔軟な課金ポリシー設定
|
||||
- ✅ 合法的に許可されたシナリオでの内部チャージとクォータ割り当て(EPay、Stripe)
|
||||
- ✅ 組織レベルのリクエスト単位、使用量ベース、キャッシュヒットのコスト会計
|
||||
- ✅ OpenAI、Azure、DeepSeek、Claude、Qwen などのモデルのキャッシュ課金統計
|
||||
- ✅ 内部管理または認可済み企業顧客向けの柔軟な課金ポリシー
|
||||
|
||||
### 🔐 認証とセキュリティ
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO認証ログイン
|
||||
- 📱 Telegram認証ログイン
|
||||
- 🔑 OIDC統一認証
|
||||
- 🔍 Key使用量クォータ照会([neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool)と併用)
|
||||
- 🔍 Key使用量クォータ照会([new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool)と併用)
|
||||
|
||||
|
||||
|
||||
@@ -256,7 +260,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 モデルサポート
|
||||
|
||||
> 詳細については[APIドキュメント - 中継インターフェース](https://docs.newapi.pro/ja/docs/api)
|
||||
> 詳細については[APIドキュメント - ゲートウェイインターフェース](https://docs.newapi.pro/ja/docs/api)
|
||||
|
||||
| モデルタイプ | 説明 | ドキュメント |
|
||||
|---------|------|------|
|
||||
@@ -268,7 +272,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messagesフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Geminiフォーマット | [ドキュメント](https://docs.newapi.pro/ja/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlowモード | - |
|
||||
| 🎯 カスタム | 完全な呼び出しアドレスの入力をサポート | - |
|
||||
| 🎯 カスタム上流 | 合法的に許可された上流エンドポイントの設定をサポート | - |
|
||||
|
||||
### 📡 サポートされているインターフェース
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| プロジェクト | 説明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | キー使用量クォータ照会ツール |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | キー使用量クォータ照会ツール |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API高性能最適化版 |
|
||||
|
||||
---
|
||||
|
||||
@@ -55,9 +55,10 @@
|
||||
## 📝 Project Description
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This project is for personal learning purposes only, with no guarantee of stability or technical support
|
||||
> - Users must comply with OpenAI's [Terms of Use](https://openai.com/policies/terms-of-use) and **applicable laws and regulations**, and must not use it for illegal purposes
|
||||
> - According to the [《Interim Measures for the Management of Generative Artificial Intelligence Services》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm), please do not provide any unregistered generative AI services to the public in China.
|
||||
> - This project is intended solely for lawful and authorized AI API gateway, organization-level authentication, multi-model management, usage analytics, cost accounting, and private deployment scenarios.
|
||||
> - Users must lawfully obtain upstream API keys, accounts, model services, and interface permissions, and must comply with upstream terms of service and applicable laws and regulations.
|
||||
> - Users should ensure their use complies with upstream terms of service and applicable laws and regulations.
|
||||
> - When providing generative AI services to the public, users should comply with applicable regulatory requirements and fulfill all filing, licensing, content safety, real-name verification, log retention, tax, and upstream authorization obligations required by their jurisdiction.
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 After deployment is complete, visit `http://localhost:3000` to start using!
|
||||
|
||||
> [!WARNING]
|
||||
> When operating this project as a public generative AI service or API resale service, users should first complete all required filing, licensing, content safety, real-name verification, log retention, tax, payment, and upstream authorization obligations.
|
||||
|
||||
📖 For more deployment methods, please refer to [Deployment Guide](https://docs.newapi.pro/en/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 Data Dashboard | Visual console and statistical analysis |
|
||||
| 🔒 Permission Management | Token grouping, model restrictions, user management |
|
||||
|
||||
### 💰 Payment and Billing
|
||||
### 💰 Authorized Usage Accounting and Billing
|
||||
|
||||
- ✅ Online recharge (EPay, Stripe)
|
||||
- ✅ Pay-per-use model pricing
|
||||
- ✅ Cache billing support (OpenAI, Azure, DeepSeek, Claude, Qwen and all supported models)
|
||||
- ✅ Flexible billing policy configuration
|
||||
- ✅ Internal top-up and quota allocation for lawful authorized scenarios (EPay, Stripe)
|
||||
- ✅ Organization-level per-request, usage-based, and cache-hit cost accounting
|
||||
- ✅ Cache billing statistics for OpenAI, Azure, DeepSeek, Claude, Qwen, and supported models
|
||||
- ✅ Flexible billing policies for internal management or authorized enterprise customers
|
||||
|
||||
### 🔐 Authorization and Security
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO authorization login
|
||||
- 📱 Telegram authorization login
|
||||
- 🔑 OIDC unified authentication
|
||||
- 🔍 Key quota query usage (with [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key quota query usage (with [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 Advanced Features
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 Model Support
|
||||
|
||||
> For details, please refer to [API Documentation - Relay Interface](https://docs.newapi.pro/en/docs/api)
|
||||
> For details, please refer to [API Documentation - Gateway Interface](https://docs.newapi.pro/en/docs/api)
|
||||
|
||||
| Model Type | Description | Documentation |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini format | [Documentation](https://docs.newapi.pro/en/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow mode | - |
|
||||
| 🎯 Custom | Supports complete call address | - |
|
||||
| 🎯 Custom upstream | Supports configuring legally authorized upstream endpoints | - |
|
||||
|
||||
### 📡 Supported Interfaces
|
||||
|
||||
@@ -312,6 +316,7 @@ docker run --name new-api -d --restart always \
|
||||
| `CRYPTO_SECRET` | Encryption secret (required for Redis) | - |
|
||||
| `SQL_DSN` | Database connection string | - |
|
||||
| `REDIS_CONN_STRING` | Redis connection string | - |
|
||||
| `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` |
|
||||
| `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` |
|
||||
| `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` |
|
||||
| `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` |
|
||||
@@ -416,7 +421,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| Project | Description |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key quota query tool |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key quota query tool |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API high-performance optimized version |
|
||||
|
||||
---
|
||||
@@ -447,6 +452,14 @@ Welcome all forms of contribution!
|
||||
|
||||
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
|
||||
|
||||
Additional terms under AGPLv3 Section 7 apply. Modified versions must preserve
|
||||
the author attribution notice `Frontend design and development by New API
|
||||
contributors.` in the appropriate legal notices and in any prominent about,
|
||||
legal, footer, or attribution location presented by the user interface.
|
||||
|
||||
Modified versions that present a user interface must also preserve a visible
|
||||
link to the original project: <https://github.com/QuantumNous/new-api>.
|
||||
|
||||
This is an open-source project developed based on [One API](https://github.com/songquanpeng/one-api) (MIT License).
|
||||
|
||||
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 项目说明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本项目仅供个人学习使用,不保证稳定性,且不提供任何技术支持
|
||||
> - 使用者必须在遵循 OpenAI 的 [使用条款](https://openai.com/policies/terms-of-use) 以及**法律法规**的情况下使用,不得用于非法用途
|
||||
> - 根据 [《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务
|
||||
> - 本项目仅面向合法授权的 AI API 网关、组织内部鉴权、多模型管理、用量统计、成本核算和私有化部署场景。
|
||||
> - 使用者必须合法取得上游 API Key、账号、模型服务或接口权限,并遵守上游服务条款及适用法律法规。
|
||||
> - 使用者应确保其使用方式符合上游服务条款及适用法律法规。
|
||||
> - 面向公众提供生成式人工智能服务时,使用者应遵守[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等监管要求,自行完成所在司法辖区要求的备案、许可、内容安全、实名、日志留存、税务和上游授权等合规义务。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 部署完成后,访问 `http://localhost:3000` 即可使用!
|
||||
|
||||
> [!WARNING]
|
||||
> 将本项目作为面向公众的生成式 AI 服务或 API 转售服务运营时,使用者应先完成备案、内容安全、实名、日志留存、税务、支付和上游授权等合规义务。
|
||||
|
||||
📖 更多部署方式请参考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 数据看板 | 可视化控制台与统计分析 |
|
||||
| 🔒 权限管理 | 令牌分组、模型限制、用户管理 |
|
||||
|
||||
### 💰 支付与计费
|
||||
### 💰 授权用量与成本管理
|
||||
|
||||
- ✅ 在线充值(易支付、Stripe)
|
||||
- ✅ 模型按次数收费
|
||||
- ✅ 缓存计费支持(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支持的模型)
|
||||
- ✅ 灵活的计费策略配置
|
||||
- ✅ 合法授权场景下的内部充值与额度分配(易支付、Stripe)
|
||||
- ✅ 组织内按次、按量或缓存命中成本核算
|
||||
- ✅ 支持 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的缓存计费统计
|
||||
- ✅ 面向内部管理或企业客户的灵活计费策略配置
|
||||
|
||||
### 🔐 授权与安全
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO 授权登录
|
||||
- 📱 Telegram 授权登录
|
||||
- 🔑 OIDC 统一认证
|
||||
- 🔍 Key 查询使用额度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key 查询使用额度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高级功能
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 模型支持
|
||||
|
||||
> 详情请参考 [接口文档 - 中继接口](https://docs.newapi.pro/zh/docs/api)
|
||||
> 详情请参考 [接口文档 - 网关接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型类型 | 说明 | 文档 |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文档](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自定义 | 支持完整调用地址 | - |
|
||||
| 🎯 自定义上游 | 支持配置合法授权的上游接口地址 | - |
|
||||
|
||||
### 📡 支持的接口
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 额度查询工具 |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 额度查询工具 |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能优化版 |
|
||||
|
||||
---
|
||||
|
||||
+16
-12
@@ -55,9 +55,10 @@
|
||||
## 📝 項目說明
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - 本項目僅供個人學習使用,不保證穩定性,且不提供任何技術支援
|
||||
> - 使用者必須在遵循 OpenAI 的 [使用條款](https://openai.com/policies/terms-of-use) 以及**法律法規**的情況下使用,不得用於非法用途
|
||||
> - 根據 [《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm) 的要求,請勿對中國地區公眾提供一切未經備案的生成式人工智慧服務
|
||||
> - 本專案僅面向合法授權的 AI API 閘道、組織內部鑑權、多模型管理、用量統計、成本核算和私有化部署場景。
|
||||
> - 使用者必須合法取得上游 API Key、帳號、模型服務或介面權限,並遵守上游服務條款及適用法律法規。
|
||||
> - 使用者應確保其使用方式符合上游服務條款及適用法律法規。
|
||||
> - 面向公眾提供生成式人工智慧服務時,使用者應遵守[《生成式人工智慧服務管理暫行辦法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)等監管要求,自行完成所在司法轄區要求的備案、許可、內容安全、實名、日誌留存、稅務和上游授權等合規義務。
|
||||
|
||||
---
|
||||
|
||||
@@ -151,6 +152,9 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
🎉 部署完成後,訪問 `http://localhost:3000` 即可使用!
|
||||
|
||||
> [!WARNING]
|
||||
> 將本專案作為面向公眾的生成式 AI 服務或 API 轉售服務運營時,使用者應先完成備案、內容安全、實名、日誌留存、稅務、支付和上游授權等合規義務。
|
||||
|
||||
📖 更多部署方式請參考 [部署指南](https://docs.newapi.pro/zh/docs/installation)
|
||||
|
||||
---
|
||||
@@ -189,12 +193,12 @@ docker run --name new-api -d --restart always \
|
||||
| 📈 數據看板 | 視覺化控制檯與統計分析 |
|
||||
| 🔒 權限管理 | 令牌分組、模型限制、用戶管理 |
|
||||
|
||||
### 💰 支付與計費
|
||||
### 💰 授權用量與成本管理
|
||||
|
||||
- ✅ 在線儲值(易支付、Stripe)
|
||||
- ✅ 模型按次數收費
|
||||
- ✅ 快取計費支援(OpenAI、Azure、DeepSeek、Claude、Qwen等所有支援的模型)
|
||||
- ✅ 靈活的計費策略配置
|
||||
- ✅ 合法授權場景下的內部儲值與額度分配(易支付、Stripe)
|
||||
- ✅ 組織內按次、按量或快取命中成本核算
|
||||
- ✅ 支援 OpenAI、Azure、DeepSeek、Claude、Qwen 等模型的快取計費統計
|
||||
- ✅ 面向內部管理或企業客戶的靈活計費策略配置
|
||||
|
||||
### 🔐 授權與安全
|
||||
|
||||
@@ -202,7 +206,7 @@ docker run --name new-api -d --restart always \
|
||||
- 🤖 LinuxDO 授權登錄
|
||||
- 📱 Telegram 授權登錄
|
||||
- 🔑 OIDC 統一認證
|
||||
- 🔍 Key 查詢使用額度(配合 [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool))
|
||||
- 🔍 Key 查詢使用額度(配合 [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool))
|
||||
|
||||
### 🚀 高級功能
|
||||
|
||||
@@ -254,7 +258,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
## 🤖 模型支援
|
||||
|
||||
> 詳情請參考 [接口文件 - 中繼接口](https://docs.newapi.pro/zh/docs/api)
|
||||
> 詳情請參考 [接口文件 - 閘道接口](https://docs.newapi.pro/zh/docs/api)
|
||||
|
||||
| 模型類型 | 說明 | 文件 |
|
||||
|---------|------|------|
|
||||
@@ -266,7 +270,7 @@ docker run --name new-api -d --restart always \
|
||||
| 💬 Claude | Messages 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/createmessage) |
|
||||
| 🌐 Gemini | Google Gemini 格式 | [文件](https://docs.newapi.pro/zh/docs/api/ai-model/chat/gemini/geminirelayv1beta) |
|
||||
| 🔧 Dify | ChatFlow 模式 | - |
|
||||
| 🎯 自訂 | 支援完整調用位址 | - |
|
||||
| 🎯 自訂上游 | 支援配置合法授權的上游介面位址 | - |
|
||||
|
||||
### 📡 支援的接口
|
||||
|
||||
@@ -416,7 +420,7 @@ docker run --name new-api -d --restart always \
|
||||
|
||||
| 項目 | 說明 |
|
||||
|------|------|
|
||||
| [neko-api-key-tool](https://github.com/Calcium-Ion/neko-api-key-tool) | Key 額度查詢工具 |
|
||||
| [new-api-key-tool](https://github.com/Calcium-Ion/new-api-key-tool) | Key 額度查詢工具 |
|
||||
| [new-api-horizon](https://github.com/Calcium-Ion/new-api-horizon) | New API 高性能優化版 |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
# Third-Party Licenses
|
||||
|
||||
This file summarizes direct third-party dependencies used by distributed builds of this project.
|
||||
It is an engineering compliance artifact and should be kept with Docker images, standalone binaries, frontend bundles, and Electron installers.
|
||||
|
||||
Scope: direct dependencies from `go.mod`, `web/default/package.json`, `web/classic/package.json`, and `electron/package.json`.
|
||||
Transitive dependencies should be audited before a final external release.
|
||||
|
||||
## Dependency Inventory
|
||||
|
||||
| Area | Scope | Ecosystem | Dependency | Version | License |
|
||||
|-------------|-------------|-----------|-------------------------------------------------------|--------------------------------------|----------------------------------------------------|
|
||||
| backend | production | Go | `github.com/Calcium-Ion/go-epay` | `v0.0.4` | Proprietary/Internal - owned by project maintainer |
|
||||
| backend | production | Go | `github.com/abema/go-mp4` | `v1.4.1` | MIT |
|
||||
| backend | production | Go | `github.com/andybalholm/brotli` | `v1.1.1` | MIT |
|
||||
| backend | production | Go | `github.com/anknown/ahocorasick` | `v0.0.0-20190904063843-d75dbd5169c0` | MIT |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2` | `v1.41.5` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/credentials` | `v1.19.10` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/aws-sdk-go-v2/service/bedrockruntime` | `v1.50.4` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/aws/smithy-go` | `v1.24.2` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/bytedance/gopkg` | `v0.1.3` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/gin-contrib/cors` | `v1.7.2` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/gzip` | `v0.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/sessions` | `v0.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/gin-contrib/static` | `v0.0.1` | MIT |
|
||||
| backend | production | Go | `github.com/gin-gonic/gin` | `v1.9.1` | MIT |
|
||||
| backend | production | Go | `github.com/glebarez/sqlite` | `v1.9.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-audio/aiff` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-audio/wav` | `v1.1.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/go-playground/validator/v10` | `v10.20.0` | MIT |
|
||||
| backend | production | Go | `github.com/go-redis/redis/v8` | `v8.11.5` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/go-webauthn/webauthn` | `v0.14.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/golang-jwt/jwt/v5` | `v5.3.0` | MIT |
|
||||
| backend | production | Go | `github.com/google/uuid` | `v1.6.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/gorilla/websocket` | `v1.5.0` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/grafana/pyroscope-go` | `v1.2.7` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/jfreymuth/oggvorbis` | `v1.0.5` | MIT |
|
||||
| backend | production | Go | `github.com/jinzhu/copier` | `v0.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/joho/godotenv` | `v1.5.1` | MIT |
|
||||
| backend | production | Go | `github.com/mewkiz/flac` | `v1.0.13` | Unlicense |
|
||||
| backend | production | Go | `github.com/nicksnyder/go-i18n/v2` | `v2.6.1` | MIT |
|
||||
| backend | production | Go | `github.com/pkg/errors` | `v0.9.1` | BSD-2-Clause |
|
||||
| backend | production | Go | `github.com/pquerna/otp` | `v1.5.0` | Apache-2.0 |
|
||||
| backend | production | Go | `github.com/samber/hot` | `v0.11.0` | MIT |
|
||||
| backend | production | Go | `github.com/samber/lo` | `v1.52.0` | MIT |
|
||||
| backend | production | Go | `github.com/shirou/gopsutil` | `v3.21.11+incompatible` | BSD-3-Clause |
|
||||
| backend | production | Go | `github.com/shopspring/decimal` | `v1.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/stretchr/testify` | `v1.11.1` | MIT |
|
||||
| backend | production | Go | `github.com/stripe/stripe-go/v81` | `v81.4.0` | MIT |
|
||||
| backend | production | Go | `github.com/tcolgate/mp3` | `v0.0.0-20170426193717-e79c5a46d300` | MIT |
|
||||
| backend | production | Go | `github.com/thanhpk/randstr` | `v1.0.6` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/gjson` | `v1.18.0` | MIT |
|
||||
| backend | production | Go | `github.com/tidwall/sjson` | `v1.2.5` | MIT |
|
||||
| backend | production | Go | `github.com/tiktoken-go/tokenizer` | `v0.6.2` | MIT |
|
||||
| backend | production | Go | `github.com/waffo-com/waffo-go` | `v1.3.1` | MIT |
|
||||
| backend | production | Go | `github.com/yapingcat/gomedia` | `v0.0.0-20240906162731-17feea57090c` | MIT |
|
||||
| backend | production | Go | `golang.org/x/crypto` | `v0.45.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/image` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/net` | `v0.47.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sync` | `v0.20.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/sys` | `v0.38.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `golang.org/x/text` | `v0.35.0` | BSD-3-Clause |
|
||||
| backend | production | Go | `gopkg.in/yaml.v3` | `v3.0.1` | Apache-2.0 OR MIT |
|
||||
| backend | production | Go | `gorm.io/driver/mysql` | `v1.4.3` | MIT |
|
||||
| backend | production | Go | `gorm.io/driver/postgres` | `v1.5.2` | MIT |
|
||||
| backend | production | Go | `gorm.io/gorm` | `v1.25.2` | MIT |
|
||||
| backend | production | Go | `github.com/expr-lang/expr` | `v1.17.8` | MIT |
|
||||
| web/default | production | npm | `@base-ui/react` | `1.4.1` | MIT |
|
||||
| web/default | production | npm | `@fontsource-variable/public-sans` | `5.2.7` | OFL-1.1 |
|
||||
| web/default | production | npm | `@hookform/resolvers` | `5.2.2` | MIT |
|
||||
| web/default | production | npm | `@hugeicons/core-free-icons` | `4.1.1` | MIT |
|
||||
| web/default | production | npm | `@hugeicons/react` | `1.1.6` | MIT |
|
||||
| web/default | production | npm | `@lobehub/icons` | `4.12.0` | MIT |
|
||||
| web/default | production | npm | `@tailwindcss/postcss` | `4.2.2` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-query` | `5.97.0` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-router` | `1.168.23` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-table` | `8.21.3` | MIT |
|
||||
| web/default | production | npm | `@tanstack/react-virtual` | `3.13.23` | MIT |
|
||||
| web/default | production | npm | `@visactor/react-vchart` | `2.0.21` | MIT |
|
||||
| web/default | production | npm | `@visactor/vchart` | `2.0.21` | MIT |
|
||||
| web/default | production | npm | `ai` | `6.0.158` | Apache-2.0 |
|
||||
| web/default | production | npm | `auto-skeleton-react` | `1.0.5` | MIT |
|
||||
| web/default | production | npm | `axios` | `1.15.0` | MIT |
|
||||
| web/default | production | npm | `class-variance-authority` | `0.7.1` | Apache-2.0 |
|
||||
| web/default | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web/default | production | npm | `cmdk` | `1.1.1` | MIT |
|
||||
| web/default | production | npm | `date-fns` | `4.1.0` | MIT |
|
||||
| web/default | production | npm | `dayjs` | `1.11.20` | MIT |
|
||||
| web/default | production | npm | `i18next` | `25.10.10` | MIT |
|
||||
| web/default | production | npm | `i18next-browser-languagedetector` | `8.2.1` | MIT |
|
||||
| web/default | production | npm | `input-otp` | `1.4.2` | MIT |
|
||||
| web/default | production | npm | `lucide-react` | `1.8.0` | ISC |
|
||||
| web/default | production | npm | `motion` | `12.38.0` | MIT |
|
||||
| web/default | production | npm | `nanoid` | `5.1.7` | MIT |
|
||||
| web/default | production | npm | `next-themes` | `0.4.6` | MIT |
|
||||
| web/default | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web/default | production | npm | `react` | `19.2.5` | MIT |
|
||||
| web/default | production | npm | `react-day-picker` | `9.14.0` | MIT |
|
||||
| web/default | production | npm | `react-dom` | `19.2.5` | MIT |
|
||||
| web/default | production | npm | `react-hook-form` | `7.72.1` | MIT |
|
||||
| web/default | production | npm | `react-i18next` | `16.6.6` | MIT |
|
||||
| web/default | production | npm | `react-icons` | `5.6.0` | MIT |
|
||||
| web/default | production | npm | `react-markdown` | `10.1.0` | MIT |
|
||||
| web/default | production | npm | `react-resizable-panels` | `4.11.0` | MIT |
|
||||
| web/default | production | npm | `react-top-loading-bar` | `3.0.2` | MIT |
|
||||
| web/default | production | npm | `recharts` | `3.8.0` | MIT |
|
||||
| web/default | production | npm | `rehype-raw` | `7.0.0` | MIT |
|
||||
| web/default | production | npm | `remark-gfm` | `4.0.1` | MIT |
|
||||
| web/default | production | npm | `shiki` | `4.0.2` | MIT |
|
||||
| web/default | production | npm | `sonner` | `2.0.7` | MIT |
|
||||
| web/default | production | npm | `sse.js` | `2.8.0` | Apache-2.0 |
|
||||
| web/default | production | npm | `streamdown` | `2.5.0` | Apache-2.0 |
|
||||
| web/default | production | npm | `tailwind-merge` | `3.5.0` | MIT |
|
||||
| web/default | production | npm | `tailwindcss` | `4.2.2` | MIT |
|
||||
| web/default | production | npm | `tokenlens` | `1.3.1` | MIT |
|
||||
| web/default | production | npm | `tw-animate-css` | `1.4.0` | MIT |
|
||||
| web/default | production | npm | `use-stick-to-bottom` | `1.1.3` | MIT |
|
||||
| web/default | production | npm | `vaul` | `1.1.2` | MIT |
|
||||
| web/default | production | npm | `zod` | `4.3.6` | MIT |
|
||||
| web/default | production | npm | `zustand` | `5.0.12` | MIT |
|
||||
| web/default | development | npm | `@eslint/js` | `10.0.1` | MIT |
|
||||
| web/default | development | npm | `@rsbuild/core` | `2.0.1` | MIT |
|
||||
| web/default | development | npm | `@rsbuild/plugin-react` | `2.0.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/eslint-plugin-query` | `5.97.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/react-query-devtools` | `5.97.0` | MIT |
|
||||
| web/default | development | npm | `@tanstack/react-router-devtools` | `1.166.13` | MIT |
|
||||
| web/default | development | npm | `@tanstack/router-plugin` | `1.167.23` | MIT |
|
||||
| web/default | development | npm | `@trivago/prettier-plugin-sort-imports` | `6.0.2` | Apache-2.0 |
|
||||
| web/default | development | npm | `@types/node` | `25.6.0` | MIT |
|
||||
| web/default | development | npm | `@types/react` | `19.2.14` | MIT |
|
||||
| web/default | development | npm | `@types/react-dom` | `19.2.3` | MIT |
|
||||
| web/default | development | npm | `@xyflow/react` | `12.10.2` | MIT |
|
||||
| web/default | development | npm | `embla-carousel-react` | `8.6.0` | MIT |
|
||||
| web/default | development | npm | `eslint` | `10.2.0` | MIT |
|
||||
| web/default | development | npm | `eslint-plugin-react-hooks` | `7.0.1` | MIT |
|
||||
| web/default | development | npm | `eslint-plugin-react-refresh` | `0.5.2` | MIT |
|
||||
| web/default | development | npm | `globals` | `17.4.0` | MIT |
|
||||
| web/default | development | npm | `knip` | `6.3.1` | ISC |
|
||||
| web/default | development | npm | `prettier` | `3.8.2` | MIT |
|
||||
| web/default | development | npm | `prettier-plugin-tailwindcss` | `0.7.2` | MIT |
|
||||
| web/default | development | npm | `shadcn` | `3.8.5` | MIT |
|
||||
| web/default | development | npm | `typescript` | `5.9.3` | Apache-2.0 |
|
||||
| web/default | development | npm | `typescript-eslint` | `8.58.1` | MIT |
|
||||
| web/classic | production | npm | `@douyinfe/semi-icons` | `2.72.2` | MIT |
|
||||
| web/classic | production | npm | `@douyinfe/semi-ui` | `2.72.2` | MIT |
|
||||
| web/classic | production | npm | `@lobehub/icons` | `2.1.0` | MIT |
|
||||
| web/classic | production | npm | `@visactor/react-vchart` | `1.8.11` | MIT |
|
||||
| web/classic | production | npm | `@visactor/vchart` | `1.8.11` | MIT |
|
||||
| web/classic | production | npm | `@visactor/vchart-semi-theme` | `1.8.8` | MIT |
|
||||
| web/classic | production | npm | `axios` | `1.15.0` | MIT |
|
||||
| web/classic | production | npm | `clsx` | `2.1.1` | MIT |
|
||||
| web/classic | production | npm | `dayjs` | `1.11.13` | MIT |
|
||||
| web/classic | production | npm | `history` | `5.3.0` | MIT |
|
||||
| web/classic | production | npm | `i18next` | `23.16.8` | MIT |
|
||||
| web/classic | production | npm | `i18next-browser-languagedetector` | `7.2.2` | MIT |
|
||||
| web/classic | production | npm | `katex` | `0.16.22` | MIT |
|
||||
| web/classic | production | npm | `lucide-react` | `0.511.0` | ISC |
|
||||
| web/classic | production | npm | `marked` | `4.3.0` | MIT |
|
||||
| web/classic | production | npm | `mermaid` | `11.6.0` | MIT |
|
||||
| web/classic | production | npm | `qrcode.react` | `4.2.0` | ISC |
|
||||
| web/classic | production | npm | `react` | `18.3.1` | MIT |
|
||||
| web/classic | production | npm | `react-dom` | `18.3.1` | MIT |
|
||||
| web/classic | production | npm | `react-dropzone` | `14.3.5` | MIT |
|
||||
| web/classic | production | npm | `react-fireworks` | `1.0.4` | ISC |
|
||||
| web/classic | production | npm | `react-i18next` | `13.5.0` | MIT |
|
||||
| web/classic | production | npm | `react-icons` | `5.5.0` | MIT |
|
||||
| web/classic | production | npm | `react-markdown` | `10.1.0` | MIT |
|
||||
| web/classic | production | npm | `react-router-dom` | `6.28.1` | MIT |
|
||||
| web/classic | production | npm | `react-telegram-login` | `1.1.2` | MIT |
|
||||
| web/classic | production | npm | `react-toastify` | `9.1.3` | MIT |
|
||||
| web/classic | production | npm | `react-turnstile` | `1.1.4` | MIT |
|
||||
| web/classic | production | npm | `rehype-highlight` | `7.0.2` | MIT |
|
||||
| web/classic | production | npm | `rehype-katex` | `7.0.1` | MIT |
|
||||
| web/classic | production | npm | `remark-breaks` | `4.0.0` | MIT |
|
||||
| web/classic | production | npm | `remark-gfm` | `4.0.1` | MIT |
|
||||
| web/classic | production | npm | `remark-math` | `6.0.0` | MIT |
|
||||
| web/classic | production | npm | `sse.js` | `2.6.0` | Apache-2.0 |
|
||||
| web/classic | production | npm | `unist-util-visit` | `5.0.0` | MIT |
|
||||
| web/classic | production | npm | `use-debounce` | `10.0.4` | MIT |
|
||||
| web/classic | development | npm | `@douyinfe/vite-plugin-semi` | `2.74.0-alpha.6` | MIT |
|
||||
| web/classic | development | npm | `@so1ve/prettier-config` | `3.1.0` | MIT |
|
||||
| web/classic | development | npm | `@vitejs/plugin-react` | `4.3.4` | MIT |
|
||||
| web/classic | development | npm | `autoprefixer` | `10.4.21` | MIT |
|
||||
| web/classic | development | npm | `code-inspector-plugin` | `1.3.3` | MIT |
|
||||
| web/classic | development | npm | `eslint` | `8.57.0` | MIT |
|
||||
| web/classic | development | npm | `eslint-plugin-header` | `3.1.1` | MIT |
|
||||
| web/classic | development | npm | `eslint-plugin-react-hooks` | `5.2.0` | MIT |
|
||||
| web/classic | development | npm | `i18next-cli` | `1.15.0` | MIT |
|
||||
| web/classic | development | npm | `postcss` | `8.5.3` | MIT |
|
||||
| web/classic | development | npm | `prettier` | `3.4.2` | MIT |
|
||||
| web/classic | development | npm | `tailwindcss` | `3.4.17` | MIT |
|
||||
| web/classic | development | npm | `typescript` | `4.4.2` | Apache-2.0 |
|
||||
| web/classic | development | npm | `vite` | `5.4.11` | MIT |
|
||||
| electron | development | npm | `cross-env` | `7.0.3` | MIT |
|
||||
| electron | development | npm | `electron` | `39.8.5` | MIT |
|
||||
| electron | development | npm | `electron-builder` | `26.7.0` | MIT |
|
||||
|
||||
## License Texts
|
||||
|
||||
### Apache-2.0
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
use this file except in compliance with the License. You may obtain a copy of
|
||||
the License at:
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
License for the specific language governing permissions and limitations under
|
||||
the License.
|
||||
|
||||
### Apache-2.0 OR MIT
|
||||
|
||||
Dual-licensed components may be used under Apache-2.0 or MIT. Both standard license texts are included below.
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
https://www.apache.org/licenses/
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); you may not
|
||||
use this file except in compliance with the License. You may obtain a copy of
|
||||
the License at:
|
||||
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
License for the specific language governing permissions and limitations under
|
||||
the License.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### BSD-2-Clause
|
||||
|
||||
BSD 2-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### BSD-3-Clause
|
||||
|
||||
BSD 3-Clause License
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software
|
||||
without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
### ISC
|
||||
|
||||
ISC License
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
### MIT
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
### OFL-1.1
|
||||
|
||||
SIL Open Font License 1.1
|
||||
|
||||
The font dependency listed under OFL-1.1 is licensed under the SIL Open Font
|
||||
License, Version 1.1. The full license text is available at:
|
||||
https://openfontlicense.org/open-font-license-official-text/
|
||||
|
||||
When distributing font files, preserve the OFL license text, copyright notices,
|
||||
and reserved font name restrictions supplied by the upstream font project.
|
||||
|
||||
### Proprietary/Internal - owned by project maintainer
|
||||
|
||||
This dependency is owned by the project maintainer and is not treated as a third-party open source dependency for this review.
|
||||
|
||||
### Unlicense
|
||||
|
||||
The Unlicense
|
||||
|
||||
This is free and unencumbered software released into the public domain.
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
|
||||
this software, either in source code form or as a compiled binary, for any
|
||||
purpose, commercial or non-commercial, and by any means.
|
||||
|
||||
For more information, please refer to https://unlicense.org/
|
||||
|
||||
+25
-2
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/tls"
|
||||
//"os"
|
||||
//"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -13,7 +14,7 @@ import (
|
||||
|
||||
var StartTime = time.Now().Unix() // unit: second
|
||||
var Version = "v0.0.0" // this hard coding will be replaced automatically when building, no need to manually change
|
||||
var SystemName = "New API"
|
||||
var SystemName = "ModelsToken"
|
||||
var Footer = ""
|
||||
var Logo = ""
|
||||
var TopUpLink = ""
|
||||
@@ -36,6 +37,26 @@ func SetTheme(t string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeAwarePath rewrites legacy /console/* paths to the default-theme
|
||||
// equivalents when the active theme is "default". For "classic" (or any
|
||||
// other theme) the path is returned unchanged. The function only touches
|
||||
// known prefixes so it is safe to call with arbitrary suffixes and query
|
||||
// strings.
|
||||
func ThemeAwarePath(suffix string) string {
|
||||
if GetTheme() != "default" {
|
||||
return suffix
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(suffix, "/console/topup"):
|
||||
return strings.Replace(suffix, "/console/topup", "/wallet", 1)
|
||||
case strings.HasPrefix(suffix, "/console/log"):
|
||||
return strings.Replace(suffix, "/console/log", "/usage-logs", 1)
|
||||
case strings.HasPrefix(suffix, "/console/personal"):
|
||||
return strings.Replace(suffix, "/console/personal", "/profile", 1)
|
||||
}
|
||||
return suffix
|
||||
}
|
||||
|
||||
// var ChatLink = ""
|
||||
// var ChatLink2 = ""
|
||||
var QuotaPerUnit = 500 * 1000.0 // $0.002 / 1K tokens
|
||||
@@ -149,6 +170,7 @@ var BatchUpdateInterval int
|
||||
|
||||
var RelayTimeout int // unit is second
|
||||
|
||||
var RelayIdleConnTimeout int // unit is second
|
||||
var RelayMaxIdleConns int
|
||||
var RelayMaxIdleConnsPerHost int
|
||||
|
||||
@@ -158,7 +180,8 @@ var GeminiSafetySetting string
|
||||
var CohereSafetySetting string
|
||||
|
||||
const (
|
||||
RequestIdKey = "X-Oneapi-Request-Id"
|
||||
RequestIdKey = "X-Oneapi-Request-Id"
|
||||
UpstreamRequestIdKey = "X-Upstream-Request-Id"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -37,7 +37,7 @@ func checkWriter(writer io.Writer) stringWriter {
|
||||
// W3C Working Draft 29 October 2009
|
||||
// http://www.w3.org/TR/2009/WD-eventsource-20091029/
|
||||
|
||||
var contentType = []string{"text/event-stream"}
|
||||
var writeContentType = []string{"text/event-stream"}
|
||||
var noCache = []string{"no-cache"}
|
||||
|
||||
var fieldReplacer = strings.NewReplacer(
|
||||
@@ -79,7 +79,7 @@ func (r CustomEvent) WriteContentType(w http.ResponseWriter) {
|
||||
r.Mutex.Lock()
|
||||
defer r.Mutex.Unlock()
|
||||
header := w.Header()
|
||||
header["Content-Type"] = contentType
|
||||
header["Content-Type"] = writeContentType
|
||||
|
||||
if _, exist := header["Cache-Control"]; !exist {
|
||||
header["Cache-Control"] = noCache
|
||||
|
||||
@@ -51,17 +51,21 @@ type themeAwareFileSystem struct {
|
||||
}
|
||||
|
||||
func (t *themeAwareFileSystem) Exists(prefix string, path string) bool {
|
||||
if GetTheme() == "classic" {
|
||||
switch GetTheme() {
|
||||
case "classic":
|
||||
return t.classicFS.Exists(prefix, path)
|
||||
default:
|
||||
return t.defaultFS.Exists(prefix, path)
|
||||
}
|
||||
return t.defaultFS.Exists(prefix, path)
|
||||
}
|
||||
|
||||
func (t *themeAwareFileSystem) Open(name string) (http.File, error) {
|
||||
if GetTheme() == "classic" {
|
||||
switch GetTheme() {
|
||||
case "classic":
|
||||
return t.classicFS.Open(name)
|
||||
default:
|
||||
return t.defaultFS.Open(name)
|
||||
}
|
||||
return t.defaultFS.Open(name)
|
||||
}
|
||||
|
||||
func NewThemeAwareFS(defaultFS, classicFS static.ServeFileSystem) static.ServeFileSystem {
|
||||
|
||||
+19
-1
@@ -110,11 +110,29 @@ func UnmarshalBodyReusable(c *gin.Context, v any) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
|
||||
// disk-backed JSON: stream-decode directly from the file to avoid
|
||||
// materializing the entire payload back into a transient []byte
|
||||
// (diskStorage.Bytes() would ReadFull the whole file into the heap).
|
||||
if storage.IsDisk() && strings.HasPrefix(contentType, "application/json") {
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
if err := DecodeJson(storage, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contentType := c.Request.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "application/json") {
|
||||
err = Unmarshal(requestBody, v)
|
||||
} else if strings.Contains(contentType, gin.MIMEPOSTForm) {
|
||||
|
||||
+4
-2
@@ -102,6 +102,7 @@ func InitEnv() {
|
||||
SyncFrequency = GetEnvOrDefault("SYNC_FREQUENCY", 60)
|
||||
BatchUpdateInterval = GetEnvOrDefault("BATCH_UPDATE_INTERVAL", 5)
|
||||
RelayTimeout = GetEnvOrDefault("RELAY_TIMEOUT", 0)
|
||||
RelayIdleConnTimeout = GetEnvOrDefault("RELAY_IDLE_CONN_TIMEOUT", 90)
|
||||
RelayMaxIdleConns = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS", 500)
|
||||
RelayMaxIdleConnsPerHost = GetEnvOrDefault("RELAY_MAX_IDLE_CONNS_PER_HOST", 100)
|
||||
|
||||
@@ -111,11 +112,11 @@ func InitEnv() {
|
||||
|
||||
// Initialize rate limit variables
|
||||
GlobalApiRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_API_RATE_LIMIT_ENABLE", true)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 180)
|
||||
GlobalApiRateLimitNum = GetEnvOrDefault("GLOBAL_API_RATE_LIMIT", 360)
|
||||
GlobalApiRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_API_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
GlobalWebRateLimitEnable = GetEnvOrDefaultBool("GLOBAL_WEB_RATE_LIMIT_ENABLE", true)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 60)
|
||||
GlobalWebRateLimitNum = GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT", 120)
|
||||
GlobalWebRateLimitDuration = int64(GetEnvOrDefault("GLOBAL_WEB_RATE_LIMIT_DURATION", 180))
|
||||
|
||||
CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true)
|
||||
@@ -135,6 +136,7 @@ func initConstantEnv() {
|
||||
constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128)
|
||||
// MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨
|
||||
constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128)
|
||||
constant.AnonymousRequestBodyLimitKB = GetEnvOrDefault("ANONYMOUS_REQUEST_BODY_LIMIT_KB", 512)
|
||||
// ForceStreamOption 覆盖请求参数,强制返回usage信息
|
||||
constant.ForceStreamOption = GetEnvOrDefaultBool("FORCE_STREAM_OPTION", true)
|
||||
constant.CountToken = GetEnvOrDefaultBool("CountToken", true)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package common
|
||||
|
||||
import "github.com/QuantumNous/new-api/constant"
|
||||
|
||||
const defaultAnonymousRequestBodyLimitKB = 512
|
||||
|
||||
func GetAnonymousRequestBodyLimitBytes() int64 {
|
||||
limitKB := constant.AnonymousRequestBodyLimitKB
|
||||
if limitKB < 0 {
|
||||
limitKB = defaultAnonymousRequestBodyLimitKB
|
||||
}
|
||||
return int64(limitKB) << 10
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -20,6 +21,16 @@ var (
|
||||
maskApiKeyPattern = regexp.MustCompile(`(['"]?)api_key:([^\s'"]+)(['"]?)`)
|
||||
)
|
||||
|
||||
const LocalLogContentLimit = 2048
|
||||
|
||||
// LocalLogPreview limits log-only content unless debug logging is enabled.
|
||||
func LocalLogPreview(content string) string {
|
||||
if DebugEnabled || len(content) <= LocalLogContentLimit {
|
||||
return content
|
||||
}
|
||||
return fmt.Sprintf("%s... [truncated, original_length=%d, limit=%d]", content[:LocalLogContentLimit], len(content), LocalLogContentLimit)
|
||||
}
|
||||
|
||||
func GetStringIfEmpty(str string, defaultValue string) string {
|
||||
if str == "" {
|
||||
return defaultValue
|
||||
|
||||
@@ -206,4 +206,8 @@ var ChannelSpecialBases = map[string]ChannelSpecialBase{
|
||||
ClaudeBaseURL: "https://ark.cn-beijing.volces.com/api/coding",
|
||||
OpenAIBaseURL: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
},
|
||||
"tencent-coding-plan": {
|
||||
ClaudeBaseURL: "https://api.lkeap.cloud.tencent.com/coding",
|
||||
OpenAIBaseURL: "https://api.lkeap.cloud.tencent.com/coding/v3",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ var GetMediaToken bool
|
||||
var GetMediaTokenNotStream bool
|
||||
var UpdateTask bool
|
||||
var MaxRequestBodyMB int
|
||||
var AnonymousRequestBodyLimitKB int
|
||||
var AzureDefaultAPIVersion string
|
||||
var NotifyLimitCount int
|
||||
var NotificationLimitDurationMinute int
|
||||
|
||||
@@ -57,7 +57,24 @@ func normalizeChannelTestEndpoint(channel *model.Channel, modelName, endpointTyp
|
||||
return normalized
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testModel string, endpointType string, isStream bool) testResult {
|
||||
func resolveChannelTestUserID(c *gin.Context) (int, error) {
|
||||
if c != nil {
|
||||
if userID := c.GetInt("id"); userID > 0 {
|
||||
return userID, nil
|
||||
}
|
||||
}
|
||||
|
||||
var rootUser model.User
|
||||
if err := model.DB.Select("id").Where("role = ?", common.RoleRootUser).First(&rootUser).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to resolve channel test user: %w", err)
|
||||
}
|
||||
if rootUser.Id == 0 {
|
||||
return 0, errors.New("failed to resolve channel test user")
|
||||
}
|
||||
return rootUser.Id, nil
|
||||
}
|
||||
|
||||
func testChannel(channel *model.Channel, testUserID int, testModel string, endpointType string, isStream bool) testResult {
|
||||
tik := time.Now()
|
||||
var unsupportedTestChannelTypes = []int{
|
||||
constant.ChannelTypeMidjourney,
|
||||
@@ -143,7 +160,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
Header: make(http.Header),
|
||||
}
|
||||
|
||||
cache, err := model.GetUserCache(1)
|
||||
cache, err := model.GetUserCache(testUserID)
|
||||
if err != nil {
|
||||
return testResult{
|
||||
localErr: err,
|
||||
@@ -151,13 +168,13 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
}
|
||||
}
|
||||
cache.WriteContext(c)
|
||||
c.Set("id", 1)
|
||||
c.Set("id", testUserID)
|
||||
|
||||
//c.Request.Header.Set("Authorization", "Bearer "+channel.Key)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set("channel", channel.Type)
|
||||
c.Set("base_url", channel.GetBaseURL())
|
||||
group, _ := model.GetUserGroup(1, false)
|
||||
group, _ := model.GetUserGroup(testUserID, false)
|
||||
c.Set("group", group)
|
||||
|
||||
newAPIError := middleware.SetupContextForSelectedChannel(c, channel, testModel)
|
||||
@@ -484,7 +501,7 @@ func testChannel(channel *model.Channel, testModel string, endpointType string,
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
consumedTime := float64(milliseconds) / 1000.0
|
||||
other := buildTestLogOther(c, info, priceData, usage, tieredResult)
|
||||
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
|
||||
model.RecordConsumeLog(c, testUserID, model.RecordConsumeLogParams{
|
||||
ChannelId: channel.Id,
|
||||
PromptTokens: usage.PromptTokens,
|
||||
CompletionTokens: usage.CompletionTokens,
|
||||
@@ -797,7 +814,7 @@ func buildTestRequest(model string, endpointType string, channel *model.Channel,
|
||||
testRequest.StreamOptions = &dto.StreamOptions{IncludeUsage: true}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(model, "o") {
|
||||
if dto.IsOpenAIReasoningOModel(model) {
|
||||
testRequest.MaxCompletionTokens = lo.ToPtr(uint(16))
|
||||
} else if strings.Contains(model, "thinking") {
|
||||
if !strings.Contains(model, "claude") {
|
||||
@@ -834,8 +851,13 @@ func TestChannel(c *gin.Context) {
|
||||
testModel := c.Query("model")
|
||||
endpointType := c.Query("endpoint_type")
|
||||
isStream, _ := strconv.ParseBool(c.Query("stream"))
|
||||
testUserID, err := resolveChannelTestUserID(c)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, testModel, endpointType, isStream)
|
||||
result := testChannel(channel, testUserID, testModel, endpointType, isStream)
|
||||
if result.localErr != nil {
|
||||
resp := gin.H{
|
||||
"success": false,
|
||||
@@ -872,6 +894,10 @@ var testAllChannelsLock sync.Mutex
|
||||
var testAllChannelsRunning bool = false
|
||||
|
||||
func testAllChannels(notify bool) error {
|
||||
testUserID, err := resolveChannelTestUserID(nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
testAllChannelsLock.Lock()
|
||||
if testAllChannelsRunning {
|
||||
@@ -902,7 +928,7 @@ func testAllChannels(notify bool) error {
|
||||
}
|
||||
isChannelEnabled := channel.Status == common.ChannelStatusEnabled
|
||||
tik := time.Now()
|
||||
result := testChannel(channel, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
result := testChannel(channel, testUserID, "", "", shouldUseStreamForAutomaticChannelTest(channel))
|
||||
tok := time.Now()
|
||||
milliseconds := tok.Sub(tik).Milliseconds()
|
||||
|
||||
|
||||
+64
-40
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OpenAIModel struct {
|
||||
@@ -68,12 +69,33 @@ func clearChannelInfo(channel *model.Channel) {
|
||||
}
|
||||
}
|
||||
|
||||
func applyChannelStatusFilter(query *gorm.DB, statusFilter int) *gorm.DB {
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
return query.Where("status = ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
if statusFilter == 0 {
|
||||
return query.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func buildChannelListQuery(group string, statusFilter int, typeFilter int) *gorm.DB {
|
||||
query := model.DB.Model(&model.Channel{})
|
||||
query = model.ApplyChannelGroupFilter(query, group)
|
||||
query = applyChannelStatusFilter(query, statusFilter)
|
||||
if typeFilter >= 0 {
|
||||
query = query.Where("type = ?", typeFilter)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func GetAllChannels(c *gin.Context) {
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
channelData := make([]*model.Channel, 0)
|
||||
idSort, _ := strconv.ParseBool(c.Query("id_sort"))
|
||||
sortOptions := model.NewChannelSortOptions(c.Query("sort_by"), c.Query("sort_order"), idSort)
|
||||
enableTagMode, _ := strconv.ParseBool(c.Query("tag_mode"))
|
||||
groupFilter := model.NormalizeChannelGroupFilter(c.Query("group"))
|
||||
statusParam := c.Query("status")
|
||||
// statusFilter: -1 all, 1 enabled, 0 disabled (include auto & manual)
|
||||
statusFilter := parseStatusFilter(statusParam)
|
||||
@@ -89,50 +111,45 @@ func GetAllChannels(c *gin.Context) {
|
||||
var total int64
|
||||
|
||||
if enableTagMode {
|
||||
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
tags, err := model.GetPaginatedChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter), pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.SysError("failed to get paginated tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
total, err = model.CountChannelTags(buildChannelListQuery(groupFilter, statusFilter, typeFilter))
|
||||
if err != nil {
|
||||
common.SysError("failed to count tags: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag == nil || *tag == "" {
|
||||
continue
|
||||
}
|
||||
tagChannels, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
continue
|
||||
common.SysError("failed to get channels by tag: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签渠道失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
filtered := make([]*model.Channel, 0)
|
||||
for _, ch := range tagChannels {
|
||||
if statusFilter == common.ChannelStatusEnabled && ch.Status != common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if statusFilter == 0 && ch.Status == common.ChannelStatusEnabled {
|
||||
continue
|
||||
}
|
||||
if typeFilter >= 0 && ch.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, ch)
|
||||
}
|
||||
channelData = append(channelData, filtered...)
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
total, _ = model.CountAllTags()
|
||||
} else {
|
||||
baseQuery := model.DB.Model(&model.Channel{})
|
||||
if typeFilter >= 0 {
|
||||
baseQuery = baseQuery.Where("type = ?", typeFilter)
|
||||
}
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
baseQuery = baseQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
baseQuery = baseQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
if err := buildChannelListQuery(groupFilter, statusFilter, typeFilter).Count(&total).Error; err != nil {
|
||||
common.SysError("failed to count channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道数量失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
|
||||
baseQuery.Count(&total)
|
||||
|
||||
err := sortOptions.Apply(baseQuery).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
|
||||
err := sortOptions.Apply(buildChannelListQuery(groupFilter, statusFilter, typeFilter)).
|
||||
Limit(pageInfo.GetPageSize()).
|
||||
Offset(pageInfo.GetStartIdx()).
|
||||
Omit("key").
|
||||
Find(&channelData).Error
|
||||
if err != nil {
|
||||
common.SysError("failed to get channels: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
|
||||
@@ -144,17 +161,16 @@ func GetAllChannels(c *gin.Context) {
|
||||
clearChannelInfo(datum)
|
||||
}
|
||||
|
||||
countQuery := model.DB.Model(&model.Channel{})
|
||||
if statusFilter == common.ChannelStatusEnabled {
|
||||
countQuery = countQuery.Where("status = ?", common.ChannelStatusEnabled)
|
||||
} else if statusFilter == 0 {
|
||||
countQuery = countQuery.Where("status != ?", common.ChannelStatusEnabled)
|
||||
}
|
||||
countQuery := buildChannelListQuery(groupFilter, statusFilter, -1)
|
||||
var results []struct {
|
||||
Type int64
|
||||
Count int64
|
||||
}
|
||||
_ = countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error
|
||||
if err := countQuery.Select("type, count(*) as count").Group("type").Find(&results).Error; err != nil {
|
||||
common.SysError("failed to count channel types: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道类型统计失败,请稍后重试"})
|
||||
return
|
||||
}
|
||||
typeCounts := make(map[int64]int64)
|
||||
for _, r := range results {
|
||||
typeCounts[r.Type] = r.Count
|
||||
@@ -262,10 +278,18 @@ func SearchChannels(c *gin.Context) {
|
||||
}
|
||||
for _, tag := range tags {
|
||||
if tag != nil && *tag != "" {
|
||||
tagChannel, err := model.GetChannelsByTag(*tag, idSort, false, sortOptions)
|
||||
if err == nil {
|
||||
channelData = append(channelData, tagChannel...)
|
||||
var tagChannels []*model.Channel
|
||||
err := sortOptions.Apply(buildChannelListQuery(group, -1, -1).Where("tag = ?", *tag)).
|
||||
Omit("key").
|
||||
Find(&tagChannels).Error
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
channelData = append(channelData, tagChannels...)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -1194,7 +1218,7 @@ func CopyChannel(c *gin.Context) {
|
||||
}
|
||||
|
||||
// insert
|
||||
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
|
||||
if err := clone.Insert(); err != nil {
|
||||
common.SysError("failed to clone channel: " + err.Error())
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
|
||||
return
|
||||
|
||||
@@ -69,3 +69,14 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
|
||||
require.Equal(t, "base", other["matched_tier"])
|
||||
require.NotEmpty(t, other["expr_b64"])
|
||||
}
|
||||
|
||||
func TestResolveChannelTestUserIDUsesRequestUser(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Set("id", 2)
|
||||
|
||||
userID, err := resolveChannelTestUserID(ctx)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, userID)
|
||||
}
|
||||
|
||||
@@ -312,7 +312,11 @@ func fetchChannelUpstreamModelIDs(channel *model.Channel) ([]string, error) {
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
default:
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
if plan, ok := constant.ChannelSpecialBases[baseURL]; ok && plan.OpenAIBaseURL != "" {
|
||||
url = fmt.Sprintf("%s/models", plan.OpenAIBaseURL)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/v1/models", baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
key, _, apiErr := channel.GetNextEnabledKey()
|
||||
|
||||
@@ -501,7 +501,7 @@ func GetUserOAuthBindingsByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
@@ -560,7 +560,7 @@ func UnbindCustomOAuthByAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetCategories 获取文档分类列表(公开)
|
||||
func GetCategories(c *gin.Context) {
|
||||
categories, err := model.GetDocumentCategories()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, categories)
|
||||
}
|
||||
|
||||
// CreateCategory 创建文档分类(管理员)
|
||||
func CreateCategory(c *gin.Context) {
|
||||
var category model.DocumentCategory
|
||||
if err := c.ShouldBindJSON(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if category.Name == "" {
|
||||
common.ApiErrorMsg(c, "分类名称不能为空")
|
||||
return
|
||||
}
|
||||
if category.Slug == "" {
|
||||
common.ApiErrorMsg(c, "分类标识不能为空")
|
||||
return
|
||||
}
|
||||
if err := model.CreateDocumentCategory(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &category)
|
||||
}
|
||||
|
||||
// UpdateCategory 更新文档分类(管理员)
|
||||
func UpdateCategory(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var category model.DocumentCategory
|
||||
if err := c.ShouldBindJSON(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
category.Id = id
|
||||
if err := model.UpdateDocumentCategory(&category); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &category)
|
||||
}
|
||||
|
||||
// DeleteCategory 删除文档分类(管理员)
|
||||
func DeleteCategory(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeleteDocumentCategory(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// GetDocuments 获取文档列表(公开,根据认证状态过滤可见性)
|
||||
func GetDocuments(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
categoryIdStr := c.Query("category_id")
|
||||
|
||||
var categoryId *int
|
||||
if categoryIdStr != "" {
|
||||
id, err := strconv.Atoi(categoryIdStr)
|
||||
if err == nil {
|
||||
categoryId = &id
|
||||
}
|
||||
}
|
||||
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
|
||||
// 根据用户认证状态决定可见性过滤
|
||||
visibility := c.Query("visibility")
|
||||
role := c.GetInt("role")
|
||||
|
||||
var documents []*model.Document
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
if role >= common.RoleAdminUser {
|
||||
// 管理员可看所有
|
||||
documents, total, err = model.GetDocuments(keyword, visibility, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
} else if role >= common.RoleCommonUser {
|
||||
// 普通用户只能看 public 和 auth
|
||||
if visibility == "public" || visibility == "auth" {
|
||||
documents, total, err = model.GetDocuments(keyword, visibility, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
} else {
|
||||
documents, total, err = model.GetDocumentsByVisibility(keyword, []string{"public", "auth"}, categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
}
|
||||
} else {
|
||||
// 未登录用户只能看 public
|
||||
documents, total, err = model.GetDocuments(keyword, "public", categoryId, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(documents)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
|
||||
// GetDocument 获取单个文档(根据可见性检查权限)
|
||||
func GetDocument(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
doc, err := model.GetDocumentBySlug(slug)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查可见性权限
|
||||
role := c.GetInt("role")
|
||||
switch doc.Visibility {
|
||||
case "admin":
|
||||
if role < common.RoleAdminUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权访问该文档",
|
||||
})
|
||||
return
|
||||
}
|
||||
case "auth":
|
||||
if role < common.RoleCommonUser {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请先登录后查看该文档",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, doc)
|
||||
}
|
||||
|
||||
// CreateDocument 创建文档(管理员)
|
||||
func CreateDocument(c *gin.Context) {
|
||||
var doc model.Document
|
||||
if err := c.ShouldBindJSON(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if doc.Title == "" {
|
||||
common.ApiErrorMsg(c, "文档标题不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Slug == "" {
|
||||
common.ApiErrorMsg(c, "文档标识不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Content == "" {
|
||||
common.ApiErrorMsg(c, "文档内容不能为空")
|
||||
return
|
||||
}
|
||||
if doc.Visibility == "" {
|
||||
doc.Visibility = "public"
|
||||
}
|
||||
doc.AuthorId = c.GetInt("id")
|
||||
if err := model.CreateDocument(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &doc)
|
||||
}
|
||||
|
||||
// UpdateDocument 更新文档(管理员,自动创建版本记录)
|
||||
func UpdateDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
var doc model.Document
|
||||
if err := c.ShouldBindJSON(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
doc.Id = id
|
||||
|
||||
// 获取旧文档内容,自动创建版本记录
|
||||
oldDoc, err := model.GetDocumentById(id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
version := &model.DocumentVersion{
|
||||
DocumentId: oldDoc.Id,
|
||||
Content: oldDoc.Content,
|
||||
AuthorId: oldDoc.AuthorId,
|
||||
}
|
||||
if err := model.CreateDocumentVersion(version); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.UpdateDocument(&doc); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, &doc)
|
||||
}
|
||||
|
||||
// DeleteDocument 删除文档(管理员)
|
||||
func DeleteDocument(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if err := model.DeleteDocument(id); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// GetDocumentVersions 获取文档版本历史(管理员)
|
||||
func GetDocumentVersions(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
versions, total, err := model.GetDocumentVersions(id, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
pageInfo.SetTotal(int(total))
|
||||
pageInfo.SetItems(versions)
|
||||
common.ApiSuccess(c, pageInfo)
|
||||
}
|
||||
+4
-2
@@ -21,7 +21,8 @@ func GetAllLogs(c *gin.Context) {
|
||||
channel, _ := strconv.Atoi(c.Query("channel"))
|
||||
group := c.Query("group")
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
|
||||
upstreamRequestId := c.Query("upstream_request_id")
|
||||
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId, upstreamRequestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -42,7 +43,8 @@ func GetUserLogs(c *gin.Context) {
|
||||
modelName := c.Query("model_name")
|
||||
group := c.Query("group")
|
||||
requestId := c.Query("request_id")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
|
||||
upstreamRequestId := c.Query("upstream_request_id")
|
||||
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId, upstreamRequestId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
|
||||
@@ -87,6 +87,9 @@ func GetStatus(c *gin.Context) {
|
||||
"chats": setting.Chats,
|
||||
"demo_site_enabled": operation_setting.DemoSiteEnabled,
|
||||
"self_use_mode_enabled": operation_setting.SelfUseModeEnabled,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_login_enabled": common.PasswordLoginEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"default_use_auto_group": setting.DefaultUseAutoGroup,
|
||||
|
||||
"usd_exchange_rate": operation_setting.USDExchangeRate,
|
||||
|
||||
+120
-43
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -109,9 +110,102 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func ListModels(c *gin.Context, modelType int) {
|
||||
userOpenAiModels := make([]dto.OpenAIModels, 0)
|
||||
func channelOwnerName(channelType int) string {
|
||||
apiType, success := common.ChannelType2APIType(channelType)
|
||||
if !success {
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
adaptor := relay.GetAdaptor(apiType)
|
||||
if adaptor == nil {
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
adaptor.Init(&relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{
|
||||
ChannelType: channelType,
|
||||
}})
|
||||
if name := strings.TrimSpace(adaptor.GetChannelName()); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.ToLower(constant.GetChannelTypeName(channelType))
|
||||
}
|
||||
|
||||
func getPreferredModelOwners(modelNames []string, groups []string) map[string]string {
|
||||
channelTypes, err := model.GetPreferredModelOwnerChannelTypes(modelNames, groups)
|
||||
if err != nil {
|
||||
common.SysLog(fmt.Sprintf("GetPreferredModelOwnerChannelTypes error: %v", err))
|
||||
return map[string]string{}
|
||||
}
|
||||
|
||||
ownerByChannelType := make(map[int]string)
|
||||
owners := make(map[string]string, len(channelTypes))
|
||||
for modelName, channelType := range channelTypes {
|
||||
owner, ok := ownerByChannelType[channelType]
|
||||
if !ok {
|
||||
owner = channelOwnerName(channelType)
|
||||
ownerByChannelType[channelType] = owner
|
||||
}
|
||||
if owner != "" {
|
||||
owners[modelName] = owner
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
func buildOpenAIModel(modelName string, ownerByModel map[string]string) dto.OpenAIModels {
|
||||
var oaiModel dto.OpenAIModels
|
||||
if staticModel, ok := openAIModelsMap[modelName]; ok {
|
||||
oaiModel = staticModel
|
||||
} else {
|
||||
oaiModel = dto.OpenAIModels{
|
||||
Id: modelName,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
}
|
||||
}
|
||||
if owner, ok := ownerByModel[modelName]; ok && owner != "" {
|
||||
oaiModel.OwnedBy = owner
|
||||
}
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
|
||||
return oaiModel
|
||||
}
|
||||
|
||||
type modelListGroups struct {
|
||||
userGroup string
|
||||
tokenGroup string
|
||||
ownerGroups []string
|
||||
}
|
||||
|
||||
func getModelListGroups(c *gin.Context) (modelListGroups, error) {
|
||||
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
if userGroup == "" && (tokenGroup == "" || tokenGroup == "auto") {
|
||||
var err error
|
||||
userGroup, err = model.GetUserGroup(c.GetInt("id"), false)
|
||||
if err != nil {
|
||||
return modelListGroups{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if tokenGroup == "auto" {
|
||||
return modelListGroups{
|
||||
userGroup: userGroup,
|
||||
tokenGroup: tokenGroup,
|
||||
ownerGroups: service.GetUserAutoGroup(userGroup),
|
||||
}, nil
|
||||
}
|
||||
|
||||
group := userGroup
|
||||
if tokenGroup != "" {
|
||||
group = tokenGroup
|
||||
}
|
||||
return modelListGroups{
|
||||
userGroup: userGroup,
|
||||
tokenGroup: tokenGroup,
|
||||
ownerGroups: []string{group},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func ListModels(c *gin.Context, modelType int) {
|
||||
acceptUnsetRatioModel := operation_setting.SelfUseModeEnabled
|
||||
if !acceptUnsetRatioModel {
|
||||
userId := c.GetInt("id")
|
||||
@@ -123,6 +217,16 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
}
|
||||
|
||||
userModelNames := make([]string, 0)
|
||||
groups, err := getModelListGroups(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "get user group failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
ownerGroups := groups.ownerGroups
|
||||
modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled)
|
||||
if modelLimitEnable {
|
||||
s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit)
|
||||
@@ -138,37 +242,12 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[allowModel]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(allowModel)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
} else {
|
||||
userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{
|
||||
Id: allowModel,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
SupportedEndpointTypes: model.GetModelSupportEndpointTypes(allowModel),
|
||||
})
|
||||
}
|
||||
userModelNames = append(userModelNames, allowModel)
|
||||
}
|
||||
} else {
|
||||
userId := c.GetInt("id")
|
||||
userGroup, err := model.GetUserGroup(userId, false)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "get user group failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
group := userGroup
|
||||
tokenGroup := common.GetContextKeyString(c, constant.ContextKeyTokenGroup)
|
||||
if tokenGroup != "" {
|
||||
group = tokenGroup
|
||||
}
|
||||
var models []string
|
||||
if tokenGroup == "auto" {
|
||||
for _, autoGroup := range service.GetUserAutoGroup(userGroup) {
|
||||
if groups.tokenGroup == "auto" {
|
||||
for _, autoGroup := range ownerGroups {
|
||||
groupModels := model.GetGroupEnabledModels(autoGroup)
|
||||
for _, g := range groupModels {
|
||||
if !common.StringsContains(models, g) {
|
||||
@@ -177,7 +256,7 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
models = model.GetGroupEnabledModels(group)
|
||||
models = model.GetGroupEnabledModels(ownerGroups[0])
|
||||
}
|
||||
for _, modelName := range models {
|
||||
if !acceptUnsetRatioModel {
|
||||
@@ -185,21 +264,19 @@ func ListModels(c *gin.Context, modelType int) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if oaiModel, ok := openAIModelsMap[modelName]; ok {
|
||||
oaiModel.SupportedEndpointTypes = model.GetModelSupportEndpointTypes(modelName)
|
||||
userOpenAiModels = append(userOpenAiModels, oaiModel)
|
||||
} else {
|
||||
userOpenAiModels = append(userOpenAiModels, dto.OpenAIModels{
|
||||
Id: modelName,
|
||||
Object: "model",
|
||||
Created: 1626777600,
|
||||
OwnedBy: "custom",
|
||||
SupportedEndpointTypes: model.GetModelSupportEndpointTypes(modelName),
|
||||
})
|
||||
}
|
||||
userModelNames = append(userModelNames, modelName)
|
||||
}
|
||||
}
|
||||
|
||||
ownerByModel := map[string]string{}
|
||||
if len(ownerGroups) > 0 {
|
||||
ownerByModel = getPreferredModelOwners(userModelNames, ownerGroups)
|
||||
}
|
||||
userOpenAiModels := make([]dto.OpenAIModels, 0, len(userModelNames))
|
||||
for _, modelName := range userModelNames {
|
||||
userOpenAiModels = append(userOpenAiModels, buildOpenAIModel(modelName, ownerByModel))
|
||||
}
|
||||
|
||||
switch modelType {
|
||||
case constant.ChannelTypeAnthropic:
|
||||
useranthropicModels := make([]dto.AnthropicModel, len(userOpenAiModels))
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelOwnerNameUsesAdaptorChannelName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
channelType int
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "openai",
|
||||
channelType: constant.ChannelTypeOpenAI,
|
||||
expected: "openai",
|
||||
},
|
||||
{
|
||||
name: "codex",
|
||||
channelType: constant.ChannelTypeCodex,
|
||||
expected: "codex",
|
||||
},
|
||||
{
|
||||
name: "openrouter",
|
||||
channelType: constant.ChannelTypeOpenRouter,
|
||||
expected: "openrouter",
|
||||
},
|
||||
{
|
||||
name: "azure fallback",
|
||||
channelType: constant.ChannelTypeAzure,
|
||||
expected: "azure",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.expected, channelOwnerName(tt.channelType))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpenAIModelOverridesOwnedBy(t *testing.T) {
|
||||
modelItem := buildOpenAIModel("gpt-5.4", map[string]string{"gpt-5.4": "openai"})
|
||||
require.Equal(t, "gpt-5.4", modelItem.Id)
|
||||
require.Equal(t, "openai", modelItem.OwnedBy)
|
||||
}
|
||||
|
||||
func TestBuildOpenAIModelFallsBackToCustomForUnknownModels(t *testing.T) {
|
||||
modelItem := buildOpenAIModel("custom-test-model", nil)
|
||||
require.Equal(t, "custom-test-model", modelItem.Id)
|
||||
require.Equal(t, "custom", modelItem.OwnedBy)
|
||||
}
|
||||
|
||||
func TestGetModelListGroupsUsesUserGroupWhenTokenGroupIsEmpty(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
|
||||
groups, err := getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "default", groups.userGroup)
|
||||
require.Empty(t, groups.tokenGroup)
|
||||
require.Equal(t, []string{"default"}, groups.ownerGroups)
|
||||
}
|
||||
|
||||
func TestGetModelListGroupsUsesExplicitTokenGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default")
|
||||
common.SetContextKey(ctx, constant.ContextKeyTokenGroup, "vip")
|
||||
|
||||
groups, err := getModelListGroups(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "default", groups.userGroup)
|
||||
require.Equal(t, "vip", groups.tokenGroup)
|
||||
require.Equal(t, []string{"vip"}, groups.ownerGroups)
|
||||
}
|
||||
+13
-9
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -27,13 +28,13 @@ var completionRatioMetaOptionKeys = []string{
|
||||
"AudioCompletionRatio",
|
||||
}
|
||||
|
||||
func isVisiblePublicKeyOption(key string) bool {
|
||||
switch key {
|
||||
case "WaffoPancakeWebhookPublicKey", "WaffoPancakeWebhookTestKey":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
func isPositiveOptionValue(value string) bool {
|
||||
intValue, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err == nil {
|
||||
return intValue > 0
|
||||
}
|
||||
floatValue, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
||||
return err == nil && floatValue > 0
|
||||
}
|
||||
|
||||
func collectModelNamesFromOptionValue(raw string, modelNames map[string]struct{}) {
|
||||
@@ -80,7 +81,7 @@ func GetOptions(c *gin.Context) {
|
||||
strings.HasSuffix(k, "Key") ||
|
||||
strings.HasSuffix(k, "secret") ||
|
||||
strings.HasSuffix(k, "api_key")
|
||||
if isSensitiveKey && !isVisiblePublicKeyOption(k) {
|
||||
if isSensitiveKey {
|
||||
continue
|
||||
}
|
||||
options = append(options, &model.Option{
|
||||
@@ -104,7 +105,6 @@ func GetOptions(c *gin.Context) {
|
||||
"message": "",
|
||||
"data": options,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type OptionUpdateRequest struct {
|
||||
@@ -133,6 +133,11 @@ func UpdateOption(c *gin.Context) {
|
||||
option.Value = fmt.Sprintf("%v", option.Value)
|
||||
}
|
||||
switch option.Key {
|
||||
case "QuotaForInviter", "QuotaForInvitee":
|
||||
// no compliance check needed
|
||||
default:
|
||||
}
|
||||
switch option.Key {
|
||||
case "GitHubOAuthEnabled":
|
||||
if option.Value == "true" && common.GitHubClientId == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -324,5 +329,4 @@ func UpdateOption(c *gin.Context) {
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -350,6 +350,11 @@ func AdminResetPasskey(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorMsg(c, "no permission")
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := model.GetPasskeyByUserID(user.Id); err != nil {
|
||||
if errors.Is(err, model.ErrPasskeyNotFound) {
|
||||
|
||||
@@ -61,24 +61,13 @@ func isWaffoWebhookEnabled() bool {
|
||||
}
|
||||
|
||||
func isWaffoPancakeTopUpEnabled() bool {
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
return false
|
||||
}
|
||||
|
||||
return isWaffoPancakeWebhookConfigured() &&
|
||||
strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
return strings.TrimSpace(setting.WaffoPancakeMerchantID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) != "" &&
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) != ""
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookConfigured() bool {
|
||||
currentWebhookKey := strings.TrimSpace(setting.WaffoPancakeWebhookPublicKey)
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = strings.TrimSpace(setting.WaffoPancakeWebhookTestKey)
|
||||
}
|
||||
|
||||
return currentWebhookKey != ""
|
||||
return isWaffoPancakeTopUpEnabled()
|
||||
}
|
||||
|
||||
func isWaffoPancakeWebhookEnabled() bool {
|
||||
|
||||
@@ -97,47 +97,29 @@ func TestWaffoWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWaffoPancakeWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
originalEnabled := setting.WaffoPancakeEnabled
|
||||
originalSandbox := setting.WaffoPancakeSandbox
|
||||
originalMerchantID := setting.WaffoPancakeMerchantID
|
||||
originalPrivateKey := setting.WaffoPancakePrivateKey
|
||||
originalWebhookPublicKey := setting.WaffoPancakeWebhookPublicKey
|
||||
originalWebhookTestKey := setting.WaffoPancakeWebhookTestKey
|
||||
originalStoreID := setting.WaffoPancakeStoreID
|
||||
originalProductID := setting.WaffoPancakeProductID
|
||||
t.Cleanup(func() {
|
||||
setting.WaffoPancakeEnabled = originalEnabled
|
||||
setting.WaffoPancakeSandbox = originalSandbox
|
||||
setting.WaffoPancakeMerchantID = originalMerchantID
|
||||
setting.WaffoPancakePrivateKey = originalPrivateKey
|
||||
setting.WaffoPancakeWebhookPublicKey = originalWebhookPublicKey
|
||||
setting.WaffoPancakeWebhookTestKey = originalWebhookTestKey
|
||||
setting.WaffoPancakeStoreID = originalStoreID
|
||||
setting.WaffoPancakeProductID = originalProductID
|
||||
})
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = false
|
||||
setting.WaffoPancakeMerchantID = "merchant"
|
||||
setting.WaffoPancakeMerchantID = ""
|
||||
setting.WaffoPancakePrivateKey = "private"
|
||||
setting.WaffoPancakeStoreID = "store"
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
setting.WaffoPancakeWebhookPublicKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookPublicKey = "public"
|
||||
setting.WaffoPancakeMerchantID = "merchant"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = false
|
||||
setting.WaffoPancakeProductID = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeEnabled = true
|
||||
setting.WaffoPancakeSandbox = true
|
||||
setting.WaffoPancakeWebhookTestKey = ""
|
||||
setting.WaffoPancakeProductID = "product"
|
||||
setting.WaffoPancakePrivateKey = ""
|
||||
require.False(t, isWaffoPancakeWebhookEnabled())
|
||||
|
||||
setting.WaffoPancakeWebhookTestKey = "test_public"
|
||||
require.True(t, isWaffoPancakeWebhookEnabled())
|
||||
}
|
||||
|
||||
func TestEpayWebhookEnabledRequiresTopUpAndWebhookConfig(t *testing.T) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"strconv"
|
||||
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
func GetPerfMetricsSummary(c *gin.Context) {
|
||||
@@ -17,7 +19,8 @@ func GetPerfMetricsSummary(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
result, err := perfmetrics.QuerySummaryAll(hours)
|
||||
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
|
||||
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
@@ -62,8 +65,18 @@ func GetPerfMetrics(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result.Groups = filterActiveGroups(result.Groups)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func filterActiveGroups(groups []perfmetrics.GroupResult) []perfmetrics.GroupResult {
|
||||
activeRatios := ratio_setting.GetGroupRatioCopy()
|
||||
return lo.Filter(groups, func(g perfmetrics.GroupResult, _ int) bool {
|
||||
_, ok := activeRatios[g.Group]
|
||||
return ok || g.Group == "auto"
|
||||
})
|
||||
}
|
||||
|
||||
+2
-2
@@ -88,7 +88,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
|
||||
defer func() {
|
||||
if newAPIError != nil {
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", newAPIError.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("relay error: %s", common.LocalLogPreview(newAPIError.Error())))
|
||||
newAPIError.SetMessage(common.MessageWithRequestId(newAPIError.Error(), requestId))
|
||||
switch relayFormat {
|
||||
case types.RelayFormatOpenAIRealtime:
|
||||
@@ -354,7 +354,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
}
|
||||
|
||||
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, err.Error()))
|
||||
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
|
||||
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
|
||||
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
|
||||
if service.ShouldDisableChannel(err) && channelError.AutoBan {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
)
|
||||
|
||||
func paymentReturnPath(suffix string) string {
|
||||
base := strings.TrimRight(system_setting.ServerAddress, "/")
|
||||
return base + common.ThemeAwarePath(suffix)
|
||||
}
|
||||
@@ -21,6 +21,10 @@ type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
type SubscriptionBalancePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
@@ -31,6 +35,7 @@ func GetSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -86,6 +91,21 @@ func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
func SubscriptionRequestBalancePay(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req SubscriptionBalancePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := model.PurchaseSubscriptionWithBalance(userId, req.PlanId); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
@@ -96,6 +116,7 @@ func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
p.NormalizeDefaults()
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
@@ -130,6 +151,9 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.AllowBalancePay == nil {
|
||||
req.Plan.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
@@ -234,6 +258,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"waffo_pancake_product_id": req.Plan.WaffoPancakeProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
@@ -241,6 +266,9 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if req.Plan.AllowBalancePay != nil {
|
||||
updateMap["allow_balance_pay"] = *req.Plan.AllowBalancePay
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
@@ -173,7 +172,7 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
||||
if c.Request.Method == "POST" {
|
||||
// POST 请求:从 POST body 解析参数
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
@@ -189,29 +188,29 @@ func SubscriptionEpayReturn(c *gin.Context) {
|
||||
}
|
||||
|
||||
if len(params) == 0 {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo), model.PaymentProviderEpay, verifyInfo.Type); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=fail")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=fail"))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=success")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=success"))
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/topup?pay=pending")
|
||||
c.Redirect(http.StatusFound, paymentReturnPath("/console/topup?pay=pending"))
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
@@ -111,8 +110,8 @@ func genStripeSubscriptionLink(referenceId string, customerId string, email stri
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
SuccessURL: stripe.String(paymentReturnPath("/console/topup")),
|
||||
CancelURL: stripe.String(paymentReturnPath("/console/topup")),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceId),
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionWaffoPancakePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestWaffoPancakePay(c *gin.Context) {
|
||||
var req SubscriptionWaffoPancakePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(plan.WaffoPancakeProductId) == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 WaffoPancakeProductId")
|
||||
return
|
||||
}
|
||||
// Plan targets its own Pancake product, so we only require credentials
|
||||
// here — not the gateway-level WaffoPancakeProductID.
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" {
|
||||
common.ApiErrorMsg(c, "Waffo Pancake 未配置或密钥无效")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// WAFFO_PANCAKE_SUB- prefix (vs. wallet's WAFFO_PANCAKE-) drives webhook
|
||||
// dispatch in WaffoPancakeWebhook.
|
||||
tradeNo := fmt.Sprintf("WAFFO_PANCAKE_SUB-%d-%d-%s", userId, time.Now().UnixMilli(), randstr.String(6))
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: model.PaymentMethodWaffoPancake,
|
||||
PaymentProvider: model.PaymentProviderWaffoPancake,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
expiresInSeconds := 45 * 60
|
||||
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
||||
ProductID: plan.WaffoPancakeProductId,
|
||||
BuyerIdentity: service.WaffoPancakeBuyerIdentityFromUserID(user.Id),
|
||||
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
||||
Amount: decimal.NewFromFloat(plan.PriceAmount).StringFixed(2),
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅结账会话创建失败 user_id=%d plan_id=%d trade_no=%s error=%q", userId, plan.Id, tradeNo, err.Error()))
|
||||
order.Status = common.TopUpStatusFailed
|
||||
_ = order.Update()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅订单创建成功 user_id=%d plan_id=%d trade_no=%s session_id=%s money=%.2f", userId, plan.Id, tradeNo, session.SessionID, plan.PriceAmount))
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"token": session.Token,
|
||||
"token_expires_at": session.TokenExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -96,13 +96,13 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
return fmt.Errorf("readAll failed for task %s: %w", taskId, err)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask response: %s", string(responseBody)))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask response: %s", responseBody)
|
||||
|
||||
taskResult := &relaycommon.TaskInfo{}
|
||||
// try parse as New API response format
|
||||
var responseItems dto.TaskResponse[model.Task]
|
||||
if err = common.Unmarshal(responseBody, &responseItems); err == nil && responseItems.IsSuccess() {
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask parsed as new api response format: %+v", responseItems))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask parsed as new api response format: %+v", responseItems)
|
||||
t := responseItems.Data
|
||||
taskResult.TaskID = t.TaskID
|
||||
taskResult.Status = string(t.Status)
|
||||
@@ -116,7 +116,7 @@ func updateVideoSingleTask(ctx context.Context, adaptor channel.TaskAdaptor, cha
|
||||
task.Data = redactVideoResponseBody(responseBody)
|
||||
}
|
||||
|
||||
logger.LogDebug(ctx, fmt.Sprintf("UpdateVideoSingleTask taskResult: %+v", taskResult))
|
||||
logger.LogDebug(ctx, "UpdateVideoSingleTask taskResult: %+v", taskResult)
|
||||
|
||||
now := time.Now().Unix()
|
||||
if taskResult.Status == "" {
|
||||
|
||||
@@ -66,7 +66,7 @@ func TelegramBind(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Redirect(302, "/console/personal")
|
||||
c.Redirect(302, common.ThemeAwarePath("/console/personal"))
|
||||
}
|
||||
|
||||
func TelegramLogin(c *gin.Context) {
|
||||
|
||||
+29
-27
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/Calcium-Ion/go-epay/epay"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -48,6 +47,27 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Waffo Pancake displayed above the legacy Waffo gateway.
|
||||
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
|
||||
if enableWaffoPancake {
|
||||
hasWaffoPancake := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == model.PaymentMethodWaffoPancake {
|
||||
hasWaffoPancake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaffoPancake {
|
||||
payMethods = append(payMethods, map[string]string{
|
||||
"name": "Waffo Pancake",
|
||||
"type": model.PaymentMethodWaffoPancake,
|
||||
"color": "rgba(var(--semi-orange-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用了 Waffo 支付,添加到支付方法列表
|
||||
enableWaffo := isWaffoTopUpEnabled()
|
||||
if enableWaffo {
|
||||
@@ -70,32 +90,14 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
enableWaffoPancake := isWaffoPancakeTopUpEnabled()
|
||||
if enableWaffoPancake {
|
||||
hasWaffoPancake := false
|
||||
for _, method := range payMethods {
|
||||
if method["type"] == model.PaymentMethodWaffoPancake {
|
||||
hasWaffoPancake = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWaffoPancake {
|
||||
payMethods = append(payMethods, map[string]string{
|
||||
"name": "Waffo Pancake",
|
||||
"type": model.PaymentMethodWaffoPancake,
|
||||
"color": "rgba(var(--semi-orange-5), 1)",
|
||||
"min_topup": strconv.Itoa(setting.WaffoPancakeMinTopUp),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
data := gin.H{
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"enable_online_topup": isEpayTopUpEnabled(),
|
||||
"enable_stripe_topup": isStripeTopUpEnabled(),
|
||||
"enable_creem_topup": isCreemTopUpEnabled(),
|
||||
"enable_waffo_topup": enableWaffo,
|
||||
"enable_waffo_pancake_topup": enableWaffoPancake,
|
||||
"enable_redemption": true,
|
||||
"payment_compliance_confirmed": true,
|
||||
"waffo_pay_methods": func() interface{} {
|
||||
if enableWaffo {
|
||||
return setting.GetWaffoPayMethods()
|
||||
@@ -208,7 +210,7 @@ func RequestEpay(c *gin.Context) {
|
||||
}
|
||||
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log")
|
||||
returnUrl, _ := url.Parse(paymentReturnPath("/console/log"))
|
||||
notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify")
|
||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||
tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo)
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
@@ -348,10 +347,10 @@ func genStripeLink(referenceId string, customerId string, email string, amount i
|
||||
|
||||
// Use custom URLs if provided, otherwise use defaults
|
||||
if successURL == "" {
|
||||
successURL = system_setting.ServerAddress + "/console/log"
|
||||
successURL = paymentReturnPath("/console/log")
|
||||
}
|
||||
if cancelURL == "" {
|
||||
cancelURL = system_setting.ServerAddress + "/console/topup"
|
||||
cancelURL = paymentReturnPath("/console/topup")
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
waffo "github.com/waffo-com/waffo-go"
|
||||
@@ -237,7 +236,7 @@ func RequestWaffoPay(c *gin.Context) {
|
||||
if setting.WaffoNotifyUrl != "" {
|
||||
notifyUrl = setting.WaffoNotifyUrl
|
||||
}
|
||||
returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true"
|
||||
returnUrl := paymentReturnPath("/console/topup?show_history=true")
|
||||
if setting.WaffoReturnUrl != "" {
|
||||
returnUrl = setting.WaffoReturnUrl
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/thanhpk/randstr"
|
||||
@@ -97,33 +96,257 @@ func getWaffoPancakeBuyerEmail(user *model.User) string {
|
||||
if user != nil && strings.TrimSpace(user.Email) != "" {
|
||||
return user.Email
|
||||
}
|
||||
if user != nil {
|
||||
return fmt.Sprintf("%d@new-api.local", user.Id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func getWaffoPancakeReturnURL() string {
|
||||
if strings.TrimSpace(setting.WaffoPancakeReturnURL) != "" {
|
||||
return setting.WaffoPancakeReturnURL
|
||||
// The admin config endpoints below accept typed-but-not-yet-saved creds in
|
||||
// the body and fall back to persisted creds when the body is blank (see
|
||||
// resolveWaffoPancakeAdminCreds). Only SaveWaffoPancake writes to OptionMap.
|
||||
|
||||
type waffoPancakeCredsRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
}
|
||||
|
||||
type saveWaffoPancakeRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
StoreID string `json:"store_id"`
|
||||
ProductID string `json:"product_id"`
|
||||
}
|
||||
|
||||
type createWaffoPancakePairRequest struct {
|
||||
MerchantID string `json:"merchant_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
ReturnURL string `json:"return_url"`
|
||||
}
|
||||
|
||||
// SaveWaffoPancake atomically persists all five operator-controlled fields.
|
||||
// Catalog / pair endpoints are transient — only this one writes the OptionMap.
|
||||
func SaveWaffoPancake(c *gin.Context) {
|
||||
var req saveWaffoPancakeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
return strings.TrimRight(system_setting.ServerAddress, "/") + "/console/topup?show_history=true"
|
||||
if err := service.SaveWaffoPancakeConfig(
|
||||
c.Request.Context(),
|
||||
req.MerchantID,
|
||||
req.PrivateKey,
|
||||
req.ReturnURL,
|
||||
req.StoreID,
|
||||
req.ProductID,
|
||||
); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 保存配置失败 store_id=%q product_id=%q error=%q",
|
||||
req.StoreID, req.ProductID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "保存配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"product_id": setting.WaffoPancakeProductID,
|
||||
"store_id": setting.WaffoPancakeStoreID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// resolveWaffoPancakeAdminCreds prefers body creds (typed-but-not-yet-saved
|
||||
// values, for verification) and falls back to persisted creds when the body
|
||||
// is blank (so returning admins don't have to re-paste the private key,
|
||||
// which is stripped from GET /api/option/).
|
||||
func resolveWaffoPancakeAdminCreds(bodyMerchantID, bodyPrivateKey string) (string, string) {
|
||||
m := strings.TrimSpace(bodyMerchantID)
|
||||
k := strings.TrimSpace(bodyPrivateKey)
|
||||
if m == "" && k == "" {
|
||||
return setting.WaffoPancakeMerchantID, setting.WaffoPancakePrivateKey
|
||||
}
|
||||
return m, k
|
||||
}
|
||||
|
||||
// CreateWaffoPancakePair mints a Store + OnetimeProduct pair in one round-
|
||||
// trip. Surfaces an orphan-store flag when the product half fails so the
|
||||
// frontend can preselect / retry without losing context.
|
||||
func CreateWaffoPancakePair(c *gin.Context) {
|
||||
var req createWaffoPancakePairRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
return
|
||||
}
|
||||
result, err := service.CreateWaffoPancakePrimaryPair(
|
||||
c.Request.Context(), merchantID, privateKey, req.ReturnURL,
|
||||
)
|
||||
if err != nil {
|
||||
orphan := result != nil && result.OrphanStore
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建店铺与产品失败 orphan_store=%t store_id=%q error=%q",
|
||||
orphan, func() string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
return result.StoreID
|
||||
}(), err.Error(),
|
||||
))
|
||||
data := gin.H{"error": err.Error()}
|
||||
if orphan {
|
||||
data["store_id"] = result.StoreID
|
||||
data["store_name"] = result.StoreName
|
||||
data["orphan_store"] = true
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": data})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"store_id": result.StoreID,
|
||||
"store_name": result.StoreName,
|
||||
"product_id": result.ProductID,
|
||||
"product_name": result.ProductName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListWaffoPancakeCatalog returns the merchant's Stores + OnetimeProducts.
|
||||
// Doubles as a credential probe (a successful 200 proves the resolved creds
|
||||
// authenticate). See resolveWaffoPancakeAdminCreds for credential resolution.
|
||||
func ListWaffoPancakeCatalog(c *gin.Context) {
|
||||
var req waffoPancakeCredsRequest
|
||||
// An empty body means "use persisted creds"; only fail on malformed JSON.
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds(req.MerchantID, req.PrivateKey)
|
||||
if merchantID == "" || privateKey == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 凭证未配置"})
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取店铺与产品目录失败 error=%q", err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取目录失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": catalog})
|
||||
}
|
||||
|
||||
type createWaffoPancakeSubscriptionProductRequest struct {
|
||||
Name string `json:"name"`
|
||||
Amount string `json:"amount"`
|
||||
}
|
||||
|
||||
// CreateWaffoPancakeSubscriptionProduct mints an OnetimeProduct (not
|
||||
// SubscriptionProduct — see service.CreateWaffoPancakeProductForPlan)
|
||||
// sized to a plan's `name` + `amount`, using persisted Pancake credentials
|
||||
// + StoreID. Reads from the form, not the plan row, so newly-typed unsaved
|
||||
// plans can mint a product too.
|
||||
func CreateWaffoPancakeSubscriptionProduct(c *gin.Context) {
|
||||
var req createWaffoPancakeSubscriptionProductRequest
|
||||
if c.Request.ContentLength > 0 {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐名称不能为空"})
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Amount) == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "套餐价格不能为空"})
|
||||
return
|
||||
}
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
return
|
||||
}
|
||||
productID, err := service.CreateWaffoPancakeProductForPlan(
|
||||
c.Request.Context(),
|
||||
merchantID,
|
||||
privateKey,
|
||||
storeID,
|
||||
req.Name,
|
||||
req.Amount,
|
||||
setting.WaffoPancakeReturnURL,
|
||||
)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 创建套餐产品失败 store_id=%q name=%q amount=%q error=%q",
|
||||
storeID, req.Name, req.Amount, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建套餐产品失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"product_id": productID,
|
||||
"product_name": req.Name,
|
||||
"store_id": storeID,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ListWaffoPancakeSubscriptionProductOptions returns the OnetimeProducts
|
||||
// in the saved Pancake store, for the subscription-plan dropdown. The name
|
||||
// reflects new-api's plan concept; under the hood it's still OnetimeProducts.
|
||||
func ListWaffoPancakeSubscriptionProductOptions(c *gin.Context) {
|
||||
merchantID, privateKey := resolveWaffoPancakeAdminCreds("", "")
|
||||
storeID := strings.TrimSpace(setting.WaffoPancakeStoreID)
|
||||
if merchantID == "" || privateKey == "" || storeID == "" {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 未完成配置,请先在支付设置中完成网关绑定"})
|
||||
return
|
||||
}
|
||||
catalog, err := service.ListWaffoPancakeCatalog(c.Request.Context(), merchantID, privateKey)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake 拉取订阅产品列表失败 store_id=%q error=%q", storeID, err.Error(),
|
||||
))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉取产品列表失败"})
|
||||
return
|
||||
}
|
||||
products := []service.WaffoPancakeCatalogProduct{}
|
||||
for _, store := range catalog.Stores {
|
||||
if store.ID == storeID {
|
||||
products = store.OnetimeProducts
|
||||
break
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"store_id": storeID,
|
||||
"products": products,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func getWaffoPancakeBuyerIdentity(user *model.User) string {
|
||||
if user == nil {
|
||||
return ""
|
||||
}
|
||||
return service.WaffoPancakeBuyerIdentityFromUserID(user.Id)
|
||||
}
|
||||
|
||||
func RequestWaffoPancakePay(c *gin.Context) {
|
||||
if !setting.WaffoPancakeEnabled {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 支付未启用"})
|
||||
return
|
||||
}
|
||||
currentWebhookKey := setting.WaffoPancakeWebhookPublicKey
|
||||
if setting.WaffoPancakeSandbox {
|
||||
currentWebhookKey = setting.WaffoPancakeWebhookTestKey
|
||||
}
|
||||
if strings.TrimSpace(setting.WaffoPancakeMerchantID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakePrivateKey) == "" ||
|
||||
strings.TrimSpace(currentWebhookKey) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeStoreID) == "" ||
|
||||
strings.TrimSpace(setting.WaffoPancakeProductID) == "" {
|
||||
if !isWaffoPancakeTopUpEnabled() {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "Waffo Pancake 配置不完整"})
|
||||
return
|
||||
}
|
||||
@@ -176,18 +399,15 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
|
||||
expiresInSeconds := 45 * 60
|
||||
session, err := service.CreateWaffoPancakeCheckoutSession(c.Request.Context(), &service.WaffoPancakeCreateSessionParams{
|
||||
StoreID: setting.WaffoPancakeStoreID,
|
||||
ProductID: setting.WaffoPancakeProductID,
|
||||
ProductType: "onetime",
|
||||
Currency: strings.ToUpper(strings.TrimSpace(setting.WaffoPancakeCurrency)),
|
||||
ProductID: setting.WaffoPancakeProductID,
|
||||
BuyerIdentity: getWaffoPancakeBuyerIdentity(user),
|
||||
PriceSnapshot: &service.WaffoPancakePriceSnapshot{
|
||||
Amount: formatWaffoPancakeAmount(payMoney),
|
||||
TaxIncluded: false,
|
||||
TaxCategory: "saas",
|
||||
},
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
SuccessURL: getWaffoPancakeReturnURL(),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
BuyerEmail: getWaffoPancakeBuyerEmail(user),
|
||||
ExpiresInSeconds: &expiresInSeconds,
|
||||
OrderMerchantExternalID: tradeNo,
|
||||
})
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 创建结账会话失败 user_id=%d trade_no=%s error=%q", id, tradeNo, err.Error()))
|
||||
@@ -201,10 +421,12 @@ func RequestWaffoPancakePay(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"checkout_url": session.CheckoutURL,
|
||||
"session_id": session.SessionID,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"order_id": tradeNo,
|
||||
"token": session.Token,
|
||||
"token_expires_at": session.TokenExpiresAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -216,6 +438,19 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// :env splits test vs prod traffic at the routing layer — operator
|
||||
// registers each URL in the matching webhook slot in Pancake's dashboard.
|
||||
// We then enforce event.mode == expectedEnv to catch mis-registrations.
|
||||
expectedEnv := strings.TrimSpace(c.Param("env"))
|
||||
if expectedEnv != "test" && expectedEnv != "prod" {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 路径环境段无效 env=%q path=%q client_ip=%s",
|
||||
expectedEnv, c.Request.RequestURI, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusNotFound, "unknown env")
|
||||
return
|
||||
}
|
||||
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 读取请求体失败 path=%q client_ip=%s error=%q", c.Request.RequestURI, c.ClientIP(), err.Error()))
|
||||
@@ -233,15 +468,57 @@ func WaffoPancakeWebhook(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if !strings.EqualFold(strings.TrimSpace(event.Mode), expectedEnv) {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 环境不匹配 expected=%q actual_mode=%q event_id=%s order_id=%s client_ip=%s",
|
||||
expectedEnv, event.Mode, event.ID, event.Data.OrderID, c.ClientIP(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 验签成功 event_type=%s event_id=%s order_id=%s client_ip=%s", event.NormalizedEventType(), event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
if event.NormalizedEventType() != "order.completed" {
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch by trade_no prefix. OrderMerchantExternalID = our trade_no;
|
||||
// OrderID is Pancake's internal ORD_* (logs only).
|
||||
rawTradeNo := strings.TrimSpace(event.Data.OrderMerchantExternalID)
|
||||
isSubscription := strings.HasPrefix(rawTradeNo, "WAFFO_PANCAKE_SUB-")
|
||||
|
||||
if isSubscription {
|
||||
tradeNo, err := service.ResolveWaffoPancakeSubscriptionTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订阅订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
LockOrder(tradeNo)
|
||||
defer UnlockOrder(tradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(tradeNo, string(bodyBytes), model.PaymentProviderWaffoPancake, ""); err != nil {
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成失败 trade_no=%s event_id=%s order_id=%s client_ip=%s error=%q", tradeNo, event.ID, event.Data.OrderID, c.ClientIP(), err.Error()))
|
||||
c.String(http.StatusInternalServerError, "retry")
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c.Request.Context(), fmt.Sprintf("Waffo Pancake 订阅完成 trade_no=%s event_id=%s order_id=%s client_ip=%s", tradeNo, event.ID, event.Data.OrderID, c.ClientIP()))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo, err := service.ResolveWaffoPancakeTradeNo(event)
|
||||
if err != nil {
|
||||
logger.LogWarn(c.Request.Context(), fmt.Sprintf("Waffo Pancake webhook 订单号映射失败 event_id=%s order_id=%s error=%q", event.ID, event.Data.OrderID, err.Error()))
|
||||
// LogError (not LogWarn): covers order-not-found and buyer-identity
|
||||
// mismatch — both warrant human attention. 200 OK so Waffo doesn't
|
||||
// retry a permanently-unresolvable webhook.
|
||||
logger.LogError(c.Request.Context(), fmt.Sprintf(
|
||||
"Waffo Pancake webhook 订单解析失败 event_id=%s order_id=%s buyer_identity=%q client_ip=%s error=%q",
|
||||
event.ID, event.Data.OrderID, event.Data.MerchantProvidedBuyerIdentity, c.ClientIP(), err.Error(),
|
||||
))
|
||||
c.String(http.StatusOK, "OK")
|
||||
return
|
||||
}
|
||||
|
||||
+1
-1
@@ -520,7 +520,7 @@ func AdminDisable2FA(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= targetUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, targetUser.Role) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权操作同级或更高级用户的2FA设置",
|
||||
|
||||
+28
-10
@@ -250,8 +250,20 @@ func GetAllUsers(c *gin.Context) {
|
||||
func SearchUsers(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
group := c.Query("group")
|
||||
var role *int
|
||||
if roleStr := c.Query("role"); roleStr != "" {
|
||||
if parsed, err := strconv.Atoi(roleStr); err == nil {
|
||||
role = &parsed
|
||||
}
|
||||
}
|
||||
var status *int
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
if parsed, err := strconv.Atoi(statusStr); err == nil {
|
||||
status = &parsed
|
||||
}
|
||||
}
|
||||
pageInfo := common.GetPageQuery(c)
|
||||
users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
users, total, err := model.SearchUsers(keyword, group, role, status, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
@@ -263,6 +275,10 @@ func SearchUsers(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func canManageTargetRole(myRole int, targetRole int) bool {
|
||||
return myRole == common.RoleRootUser || myRole > targetRole
|
||||
}
|
||||
|
||||
func GetUser(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
@@ -275,7 +291,7 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -562,11 +578,11 @@ func UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= originUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, originUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
if myRole <= updatedUser.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, updatedUser.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserCannotCreateHigherLevel)
|
||||
return
|
||||
}
|
||||
@@ -605,7 +621,7 @@ func AdminClearUserBinding(c *gin.Context) {
|
||||
}
|
||||
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionSameLevel)
|
||||
return
|
||||
}
|
||||
@@ -773,12 +789,14 @@ func DeleteUser(c *gin.Context) {
|
||||
}
|
||||
err = model.HardDeleteUserById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
@@ -867,7 +885,7 @@ func ManageUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
myRole := c.GetInt("role")
|
||||
if myRole <= user.Role && myRole != common.RoleRootUser {
|
||||
if !canManageTargetRole(myRole, user.Role) {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserNoPermissionHigherLevel)
|
||||
return
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,7 +16,7 @@ version: '3.4' # For compatibility with older Docker versions
|
||||
|
||||
services:
|
||||
new-api:
|
||||
image: calciumion/new-api:latest
|
||||
image: git.viaeon.com/admin/new-api:latest
|
||||
container_name: new-api
|
||||
restart: always
|
||||
command: --log-dir /app/logs
|
||||
@@ -34,6 +34,7 @@ services:
|
||||
- BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update)
|
||||
- NODE_NAME=new-api-node-1 # 节点名称,用于审计日志中标识节点身份;多节点/容器部署时建议设置 (Node name used in audit logs; recommended when running multiple instances or in containers)
|
||||
# - STREAMING_TIMEOUT=300 # 流模式无响应超时时间,单位秒,默认120秒,如果出现空补全可以尝试改为更大值 (Streaming timeout in seconds, default is 120s. Increase if experiencing empty completions)
|
||||
# - RELAY_IDLE_CONN_TIMEOUT=90 # Relay HTTP 客户端空闲连接超时时间,单位秒,默认跟随 Go 标准库,设置为0表示不限制 (Relay HTTP client idle keep-alive timeout in seconds, defaults to Go standard library; set 0 to disable)
|
||||
# - SESSION_SECRET=random_string # 多机部署时设置,必须修改这个随机字符串!! (multi-node deployment, set this to a random string!!!!!!!)
|
||||
# - SYNC_FREQUENCY=60 # Uncomment if regular database syncing is needed
|
||||
# - GOOGLE_ANALYTICS_ID=G-XXXXXXXXXX # Google Analytics 的测量 ID (Google Analytics Measurement ID)
|
||||
|
||||
+6
-6
@@ -26,11 +26,11 @@ type ImageRequest struct {
|
||||
OutputFormat json.RawMessage `json:"output_format,omitempty"`
|
||||
OutputCompression json.RawMessage `json:"output_compression,omitempty"`
|
||||
PartialImages json.RawMessage `json:"partial_images,omitempty"`
|
||||
// Stream bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
Stream *bool `json:"stream,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
Mask json.RawMessage `json:"mask,omitempty"`
|
||||
InputFidelity json.RawMessage `json:"input_fidelity,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
// zhipu 4v
|
||||
WatermarkEnabled json.RawMessage `json:"watermark_enabled,omitempty"`
|
||||
UserId json.RawMessage `json:"user_id,omitempty"`
|
||||
@@ -163,7 +163,7 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
}
|
||||
|
||||
func (i *ImageRequest) IsStream(c *gin.Context) bool {
|
||||
return false
|
||||
return i.Stream != nil && *i.Stream
|
||||
}
|
||||
|
||||
func (i *ImageRequest) SetModelName(modelName string) {
|
||||
|
||||
+12
-2
@@ -213,12 +213,22 @@ func (r *GeneralOpenAIRequest) ToMap() map[string]any {
|
||||
return result
|
||||
}
|
||||
|
||||
func IsOpenAIReasoningOModel(modelName string) bool {
|
||||
return strings.HasPrefix(modelName, "o1") ||
|
||||
strings.HasPrefix(modelName, "o3") ||
|
||||
strings.HasPrefix(modelName, "o4")
|
||||
}
|
||||
|
||||
func IsOpenAIGPT5Model(modelName string) bool {
|
||||
return strings.HasPrefix(modelName, "gpt-5")
|
||||
}
|
||||
|
||||
func (r *GeneralOpenAIRequest) GetSystemRoleName() string {
|
||||
if strings.HasPrefix(r.Model, "o") {
|
||||
if IsOpenAIReasoningOModel(r.Model) {
|
||||
if !strings.HasPrefix(r.Model, "o1-mini") && !strings.HasPrefix(r.Model, "o1-preview") {
|
||||
return "developer"
|
||||
}
|
||||
} else if strings.HasPrefix(r.Model, "gpt-5") {
|
||||
} else if IsOpenAIGPT5Model(r.Model) {
|
||||
return "developer"
|
||||
}
|
||||
return "system"
|
||||
|
||||
@@ -71,3 +71,27 @@ func TestOpenAIResponsesRequestPreserveExplicitZeroValues(t *testing.T) {
|
||||
require.True(t, gjson.GetBytes(encoded, "stream").Exists())
|
||||
require.True(t, gjson.GetBytes(encoded, "top_p").Exists())
|
||||
}
|
||||
|
||||
func TestGeneralOpenAIRequestGetSystemRoleName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
want string
|
||||
}{
|
||||
{name: "o1 uses developer", model: "o1", want: "developer"},
|
||||
{name: "o3 family uses developer", model: "o3-mini-high", want: "developer"},
|
||||
{name: "o4 family uses developer", model: "o4-mini", want: "developer"},
|
||||
{name: "o1 mini stays system", model: "o1-mini", want: "system"},
|
||||
{name: "o1 preview stays system", model: "o1-preview", want: "system"},
|
||||
{name: "gpt 5 uses developer", model: "gpt-5", want: "developer"},
|
||||
{name: "omni is not o series", model: "omni-moderation-latest", want: "system"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := GeneralOpenAIRequest{Model: tt.model}
|
||||
|
||||
require.Equal(t, tt.want, req.GetSystemRoleName())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -3097,9 +3097,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
Vendored
+63
-3
@@ -18,10 +18,10 @@
|
||||
"openai",
|
||||
"claude"
|
||||
],
|
||||
"author": "QuantumNous",
|
||||
"author": "modelstoken",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/QuantumNous/new-api"
|
||||
"url": "https://git.viaeon.com/admin/new-api"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -63,6 +63,26 @@
|
||||
{
|
||||
"from": "../web/dist",
|
||||
"to": "web/dist"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -76,6 +96,26 @@
|
||||
{
|
||||
"from": "../new-api.exe",
|
||||
"to": "bin/new-api.exe"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -90,6 +130,26 @@
|
||||
{
|
||||
"from": "../new-api",
|
||||
"to": "bin/new-api"
|
||||
},
|
||||
{
|
||||
"from": "../LICENSE",
|
||||
"to": "licenses/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "../NOTICE",
|
||||
"to": "licenses/NOTICE"
|
||||
},
|
||||
{
|
||||
"from": "../THIRD-PARTY-LICENSES.md",
|
||||
"to": "licenses/THIRD-PARTY-LICENSES.md"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSE",
|
||||
"to": "licenses/electron/LICENSE"
|
||||
},
|
||||
{
|
||||
"from": "node_modules/electron/dist/LICENSES.chromium.html",
|
||||
"to": "licenses/electron/LICENSES.chromium.html"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -98,4 +158,4 @@
|
||||
"allowToChangeInstallationDirectory": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ require (
|
||||
gorm.io/gorm v1.25.2
|
||||
)
|
||||
|
||||
require github.com/waffo-com/waffo-pancake-sdk-go v0.3.1
|
||||
|
||||
require (
|
||||
github.com/DmitriyVTitov/size v1.5.0 // indirect
|
||||
github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 // indirect
|
||||
|
||||
@@ -308,6 +308,12 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw=
|
||||
github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1 h1:YOI7+3zTBlTB7Ou6+ZXnJV2JvW/ag9d7CwE/TxH3Hls=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.1.1/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0 h1:cCSgccM66p7feTtgRqUUGT50tYQOhahsoPXavd+ib1U=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.2.0/go.mod h1:5MBCGH/nqRRA5sHO/lQB/96r4BTAqy8QpWxn53m9htI=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1 h1:ngQSN/oVB35xTwFPLfg++bxPC+SptcF145Mb6c62YCc=
|
||||
github.com/waffo-com/waffo-pancake-sdk-go v0.3.1/go.mod h1:OB2MyFIQaefoPO0FV3J+yu9sDP8RVFQ+sbFsXqGuObc=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
|
||||
+10
-10
@@ -142,16 +142,16 @@ const (
|
||||
|
||||
// Payment related messages
|
||||
const (
|
||||
MsgPaymentNotConfigured = "payment.not_configured"
|
||||
MsgPaymentMethodNotExists = "payment.method_not_exists"
|
||||
MsgPaymentCallbackError = "payment.callback_error"
|
||||
MsgPaymentCreateFailed = "payment.create_failed"
|
||||
MsgPaymentStartFailed = "payment.start_failed"
|
||||
MsgPaymentAmountTooLow = "payment.amount_too_low"
|
||||
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
MsgPaymentNotConfigured = "payment.not_configured"
|
||||
MsgPaymentMethodNotExists = "payment.method_not_exists"
|
||||
MsgPaymentCallbackError = "payment.callback_error"
|
||||
MsgPaymentCreateFailed = "payment.create_failed"
|
||||
MsgPaymentStartFailed = "payment.start_failed"
|
||||
MsgPaymentAmountTooLow = "payment.amount_too_low"
|
||||
MsgPaymentStripeNotConfig = "payment.stripe_not_configured"
|
||||
MsgPaymentWebhookNotConfig = "payment.webhook_not_configured"
|
||||
MsgPaymentPriceIdNotConfig = "payment.price_id_not_configured"
|
||||
MsgPaymentCreemNotConfig = "payment.creem_not_configured"
|
||||
)
|
||||
|
||||
// Topup related messages
|
||||
|
||||
@@ -134,6 +134,7 @@ payment.stripe_not_configured: "Stripe is not configured or key is invalid"
|
||||
payment.webhook_not_configured: "Webhook is not configured"
|
||||
payment.price_id_not_configured: "StripePriceId is not configured for this plan"
|
||||
payment.creem_not_configured: "CreemProductId is not configured for this plan"
|
||||
payment.compliance_required: "Payment, redemption, subscription, and invitation reward features are disabled. The administrator must confirm compliance terms before enabling them."
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "Payment order number not provided"
|
||||
|
||||
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未配置或密钥无效"
|
||||
payment.webhook_not_configured: "Webhook 未配置"
|
||||
payment.price_id_not_configured: "该套餐未配置 StripePriceId"
|
||||
payment.creem_not_configured: "该套餐未配置 CreemProductId"
|
||||
payment.compliance_required: "支付、兑换码、订阅计划和邀请返利功能已禁用。管理员需先确认合规声明后方可启用。"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "未提供支付单号"
|
||||
|
||||
@@ -135,6 +135,7 @@ payment.stripe_not_configured: "Stripe 未設定或密鑰無效"
|
||||
payment.webhook_not_configured: "Webhook 未設定"
|
||||
payment.price_id_not_configured: "該訂閱方案未設定 StripePriceId"
|
||||
payment.creem_not_configured: "該訂閱方案未設定 CreemProductId"
|
||||
payment.compliance_required: "支付、兌換碼、訂閱方案和邀請返利功能已停用。管理員需先確認合規聲明後方可啟用。"
|
||||
|
||||
# Topup messages
|
||||
topup.not_provided: "未提供支付單號"
|
||||
|
||||
+9
-4
@@ -95,9 +95,11 @@ func LogDebug(ctx context.Context, msg string, args ...any) {
|
||||
}
|
||||
|
||||
func logHelper(ctx context.Context, level string, msg string) {
|
||||
id := ctx.Value(common.RequestIdKey)
|
||||
if id == nil {
|
||||
id = "SYSTEM"
|
||||
var id any = "SYSTEM"
|
||||
if ctx != nil {
|
||||
if requestID := ctx.Value(common.RequestIdKey); requestID != nil {
|
||||
id = requestID
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
common.LogWriterMu.RLock()
|
||||
@@ -172,10 +174,13 @@ func FormatQuota(quota int) string {
|
||||
|
||||
// LogJson 仅供测试使用 only for test
|
||||
func LogJson(ctx context.Context, msg string, obj any) {
|
||||
if !common.DebugEnabled {
|
||||
return
|
||||
}
|
||||
jsonStr, err := common.Marshal(obj)
|
||||
if err != nil {
|
||||
LogError(ctx, fmt.Sprintf("json marshal failed: %s", err.Error()))
|
||||
return
|
||||
}
|
||||
LogDebug(ctx, fmt.Sprintf("%s | %s", msg, string(jsonStr)))
|
||||
LogDebug(ctx, "%s | %s", msg, jsonStr)
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ func main() {
|
||||
common.SysLog(fmt.Sprintf("panic detected: %v", err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://git.viaeon.com/admin/new-api/issues", err),
|
||||
"type": "new_api_panic",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
FRONTEND_DIR = ./web/default
|
||||
FRONTEND_CLASSIC_DIR = ./web/classic
|
||||
BACKEND_DIR = .
|
||||
DEV_FRONTEND_DEFAULT_PORT ?= 5173
|
||||
DEV_FRONTEND_CLASSIC_PORT ?= 5174
|
||||
DEV_COMPOSE_FILE = docker-compose.dev.yml
|
||||
DEV_POSTGRES_SERVICE = postgres
|
||||
DEV_BACKEND_SERVICE = new-api
|
||||
DEV_POSTGRES_DB = new-api
|
||||
DEV_POSTGRES_USER = root
|
||||
DEV_SQLITE_PATH ?= one-api.db
|
||||
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-web dev-web-classic
|
||||
.PHONY: all build-frontend build-frontend-classic build-all-frontends start-backend dev dev-api dev-api-rebuild dev-web dev-web-classic reset-setup
|
||||
|
||||
all: build-all-frontends start-backend
|
||||
|
||||
build-frontend:
|
||||
@echo "Building default frontend..."
|
||||
@cd $(FRONTEND_DIR) && bun install && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_DIR) && DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-frontend-classic:
|
||||
@echo "Building classic frontend..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
@cd ./web && bun install --frozen-lockfile
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && VITE_REACT_APP_VERSION=$(cat ../../VERSION) bun run build
|
||||
|
||||
build-all-frontends: build-frontend build-frontend-classic
|
||||
|
||||
@@ -22,14 +32,65 @@ start-backend:
|
||||
|
||||
dev-api:
|
||||
@echo "Starting backend services (docker)..."
|
||||
@docker compose -f docker-compose.dev.yml up -d
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d
|
||||
|
||||
dev-api-rebuild:
|
||||
@echo "Rebuilding and starting backend service (docker)..."
|
||||
@docker compose -f $(DEV_COMPOSE_FILE) up -d --build $(DEV_BACKEND_SERVICE)
|
||||
|
||||
dev-web:
|
||||
@echo "Starting frontend dev server..."
|
||||
@cd $(FRONTEND_DIR) && bun install && bun run dev
|
||||
@echo "Starting both frontend dev servers..."
|
||||
@echo "Default frontend: http://localhost:$(DEV_FRONTEND_DEFAULT_PORT)"
|
||||
@echo "Classic frontend: http://localhost:$(DEV_FRONTEND_CLASSIC_PORT)"
|
||||
@cd ./web && bun install
|
||||
@(cd $(FRONTEND_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_DEFAULT_PORT)) & \
|
||||
default_pid=$$!; \
|
||||
(cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)) & \
|
||||
classic_pid=$$!; \
|
||||
trap 'kill $$default_pid $$classic_pid 2>/dev/null; wait $$default_pid $$classic_pid 2>/dev/null; exit 130' INT TERM; \
|
||||
while kill -0 $$default_pid 2>/dev/null && kill -0 $$classic_pid 2>/dev/null; do \
|
||||
sleep 1; \
|
||||
done; \
|
||||
if ! kill -0 $$default_pid 2>/dev/null; then \
|
||||
wait $$default_pid; \
|
||||
status=$$?; \
|
||||
kill $$classic_pid 2>/dev/null; \
|
||||
wait $$classic_pid 2>/dev/null; \
|
||||
exit $$status; \
|
||||
fi; \
|
||||
wait $$classic_pid; \
|
||||
status=$$?; \
|
||||
kill $$default_pid 2>/dev/null; \
|
||||
wait $$default_pid 2>/dev/null; \
|
||||
exit $$status
|
||||
|
||||
dev-web-classic:
|
||||
@echo "Starting classic frontend dev server..."
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun install && bun run dev
|
||||
@cd ./web && bun install
|
||||
@cd $(FRONTEND_CLASSIC_DIR) && bun run dev -- --host 0.0.0.0 --port $(DEV_FRONTEND_CLASSIC_PORT)
|
||||
|
||||
dev: dev-api dev-web
|
||||
|
||||
reset-setup:
|
||||
@echo "Resetting local setup wizard state..."
|
||||
@if docker compose -f $(DEV_COMPOSE_FILE) ps --services --status running | grep -qx "$(DEV_POSTGRES_SERVICE)"; then \
|
||||
echo "Detected running docker dev PostgreSQL. Removing setup record and root users..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) exec -T $(DEV_POSTGRES_SERVICE) \
|
||||
psql -U $(DEV_POSTGRES_USER) -d $(DEV_POSTGRES_DB) \
|
||||
-c 'DELETE FROM setups;' \
|
||||
-c 'DELETE FROM users WHERE role = 100;' \
|
||||
-c "DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "Restarting docker dev backend so setup status is recalculated..."; \
|
||||
docker compose -f $(DEV_COMPOSE_FILE) restart $(DEV_BACKEND_SERVICE); \
|
||||
elif db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; db_path="$${db_path%%\?*}"; [ -f "$$db_path" ]; then \
|
||||
db_path="$${SQLITE_PATH:-$(DEV_SQLITE_PATH)}"; \
|
||||
db_path="$${db_path%%\?*}"; \
|
||||
echo "Detected local SQLite database: $$db_path"; \
|
||||
sqlite3 "$$db_path" \
|
||||
"DELETE FROM setups; DELETE FROM users WHERE role = 100; DELETE FROM options WHERE key IN ('SelfUseModeEnabled', 'DemoSiteEnabled');"; \
|
||||
echo "SQLite setup state reset. Restart the local backend process before testing the setup wizard."; \
|
||||
else \
|
||||
echo "No running docker dev PostgreSQL or local SQLite database found."; \
|
||||
echo "Start the dev stack with 'make dev-api', or set SQLITE_PATH/DEV_SQLITE_PATH to your local SQLite database."; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
@@ -163,6 +163,10 @@ func TryUserAuth() func(c *gin.Context) {
|
||||
if id != nil {
|
||||
c.Set("id", id)
|
||||
}
|
||||
role := session.Get("role")
|
||||
if role != nil {
|
||||
c.Set("role", role)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type ModelRequest struct {
|
||||
@@ -100,14 +102,10 @@ func Distribute() func(c *gin.Context) {
|
||||
}
|
||||
|
||||
if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found {
|
||||
affinityUsable := false
|
||||
preferred, err := model.CacheGetChannel(preferredChannelID)
|
||||
if err == nil && preferred != nil {
|
||||
if preferred.Status != common.ChannelStatusEnabled {
|
||||
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||
abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled))
|
||||
return
|
||||
}
|
||||
} else if usingGroup == "auto" {
|
||||
if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled {
|
||||
if usingGroup == "auto" {
|
||||
userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup)
|
||||
autoGroups := service.GetUserAutoGroup(userGroup)
|
||||
for _, g := range autoGroups {
|
||||
@@ -115,6 +113,7 @@ func Distribute() func(c *gin.Context) {
|
||||
selectGroup = g
|
||||
common.SetContextKey(c, constant.ContextKeyAutoGroup, g)
|
||||
channel = preferred
|
||||
affinityUsable = true
|
||||
service.MarkChannelAffinityUsed(c, g, preferred.Id)
|
||||
break
|
||||
}
|
||||
@@ -122,9 +121,13 @@ func Distribute() func(c *gin.Context) {
|
||||
} else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) {
|
||||
channel = preferred
|
||||
selectGroup = usingGroup
|
||||
affinityUsable = true
|
||||
service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id)
|
||||
}
|
||||
}
|
||||
if !affinityUsable && !service.ShouldKeepChannelAffinityOnChannelDisabled() {
|
||||
service.ClearCurrentChannelAffinityCache(c)
|
||||
}
|
||||
}
|
||||
|
||||
if channel == nil {
|
||||
@@ -170,6 +173,14 @@ func Distribute() func(c *gin.Context) {
|
||||
// - application/x-www-form-urlencoded
|
||||
// - multipart/form-data
|
||||
func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
if strings.HasPrefix(c.Request.Header.Get("Content-Type"), "application/json") {
|
||||
modelRequest, err := getModelFromJSONBody(c)
|
||||
if err != nil {
|
||||
return nil, errors.New(i18n.T(c, i18n.MsgDistributorInvalidRequest, map[string]any{"Error": err.Error()}))
|
||||
}
|
||||
return modelRequest, nil
|
||||
}
|
||||
|
||||
var modelRequest ModelRequest
|
||||
err := common.UnmarshalBodyReusable(c, &modelRequest)
|
||||
if err != nil {
|
||||
@@ -178,6 +189,50 @@ func getModelFromRequest(c *gin.Context) (*ModelRequest, error) {
|
||||
return &modelRequest, nil
|
||||
}
|
||||
|
||||
func getModelFromJSONBody(c *gin.Context) (*ModelRequest, error) {
|
||||
storage, err := common.GetBodyStorage(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestBody, err := storage.Bytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !gjson.ValidBytes(requestBody) {
|
||||
return nil, errors.New("invalid JSON request body")
|
||||
}
|
||||
|
||||
values := gjson.GetManyBytes(requestBody, "model", "group")
|
||||
model, err := getJSONStringValue(values[0], "model")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group, err := getJSONStringValue(values[1], "group")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, seekErr := storage.Seek(0, io.SeekStart); seekErr != nil {
|
||||
return nil, seekErr
|
||||
}
|
||||
c.Request.Body = io.NopCloser(storage)
|
||||
|
||||
return &ModelRequest{
|
||||
Model: model,
|
||||
Group: group,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getJSONStringValue(result gjson.Result, field string) (string, error) {
|
||||
if !result.Exists() || result.Type == gjson.Null {
|
||||
return "", nil
|
||||
}
|
||||
if result.Type != gjson.String {
|
||||
return "", fmt.Errorf("field %s must be a string", field)
|
||||
}
|
||||
return result.String(), nil
|
||||
}
|
||||
|
||||
func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
var modelRequest ModelRequest
|
||||
shouldSelectChannel := true
|
||||
@@ -244,6 +299,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
shouldSelectChannel = false
|
||||
modelRequest.Model = getTaskOriginModelName(c)
|
||||
}
|
||||
c.Set("relay_mode", relayMode)
|
||||
} else if strings.Contains(c.Request.URL.Path, "/v1/video/generations") {
|
||||
@@ -258,6 +314,7 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
} else if c.Request.Method == http.MethodGet {
|
||||
relayMode = relayconstant.RelayModeVideoFetchByID
|
||||
shouldSelectChannel = false
|
||||
modelRequest.Model = getTaskOriginModelName(c)
|
||||
}
|
||||
if _, ok := c.Get("relay_mode"); !ok {
|
||||
c.Set("relay_mode", relayMode)
|
||||
@@ -342,6 +399,31 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) {
|
||||
return &modelRequest, shouldSelectChannel, nil
|
||||
}
|
||||
|
||||
// 修复 #4834: GET /v1/video/generations/:task_id && /v1/video/:task_id 此前不解析 model,
|
||||
// 当 token 启用「可用模型限制」时,下游 modelLimitEnable 校验会因
|
||||
// modelRequest.Model 为空而误报 "This token has no access to model"。
|
||||
// 从已存储的任务记录中回填 OriginModelName 即可让校验走在正确的模型上。
|
||||
func getTaskOriginModelName(c *gin.Context) string {
|
||||
if !common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) {
|
||||
return ""
|
||||
}
|
||||
|
||||
taskId := c.Param("task_id")
|
||||
if taskId == "" {
|
||||
// jimeng adapter
|
||||
taskId = c.GetString("task_id")
|
||||
}
|
||||
if taskId == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
if task, exist, err := model.GetByTaskId(userId, taskId); err == nil && exist && task != nil {
|
||||
return task.Properties.OriginModelName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, modelName string) *types.NewAPIError {
|
||||
c.Set("original_model", modelName) // for retry
|
||||
if channel == nil {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type headerNavAccess struct {
|
||||
Enabled bool
|
||||
RequireAuth bool
|
||||
}
|
||||
|
||||
func getHeaderNavAccess(module string) headerNavAccess {
|
||||
fallback := headerNavAccess{
|
||||
Enabled: true,
|
||||
RequireAuth: false,
|
||||
}
|
||||
|
||||
common.OptionMapRWMutex.RLock()
|
||||
raw := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMapRWMutex.RUnlock()
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := common.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return parseHeaderNavAccess(parsed[module], fallback)
|
||||
}
|
||||
|
||||
func parseHeaderNavAccess(raw any, fallback headerNavAccess) headerNavAccess {
|
||||
switch value := raw.(type) {
|
||||
case bool:
|
||||
return headerNavAccess{
|
||||
Enabled: value,
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case string:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case float64:
|
||||
return headerNavAccess{
|
||||
Enabled: parseHeaderNavBool(value, fallback.Enabled),
|
||||
RequireAuth: fallback.RequireAuth,
|
||||
}
|
||||
case map[string]any:
|
||||
access := fallback
|
||||
if enabled, ok := value["enabled"]; ok {
|
||||
access.Enabled = parseHeaderNavBool(enabled, fallback.Enabled)
|
||||
}
|
||||
if requireAuth, ok := value["requireAuth"]; ok {
|
||||
access.RequireAuth = parseHeaderNavBool(requireAuth, fallback.RequireAuth)
|
||||
}
|
||||
return access
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func parseHeaderNavBool(value any, fallback bool) bool {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1":
|
||||
return true
|
||||
case "false", "0":
|
||||
return false
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
case float64:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
case int:
|
||||
if v == 1 {
|
||||
return true
|
||||
}
|
||||
if v == 0 {
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModuleAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled {
|
||||
c.JSON(http.StatusForbidden, gin.H{
|
||||
"success": false,
|
||||
"message": fmt.Sprintf("%s is disabled", module),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
|
||||
func HeaderNavModulePublicOrUserAuth(module string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
access := getHeaderNavAccess(module)
|
||||
if !access.Enabled || access.RequireAuth {
|
||||
UserAuth()(c)
|
||||
return
|
||||
}
|
||||
|
||||
TryUserAuth()(c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func withHeaderNavModules(t *testing.T, raw string) {
|
||||
t.Helper()
|
||||
|
||||
common.OptionMapRWMutex.Lock()
|
||||
if common.OptionMap == nil {
|
||||
common.OptionMap = map[string]string{}
|
||||
}
|
||||
previous, hadPrevious := common.OptionMap["HeaderNavModules"]
|
||||
common.OptionMap["HeaderNavModules"] = raw
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
|
||||
t.Cleanup(func() {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
defer common.OptionMapRWMutex.Unlock()
|
||||
if hadPrevious {
|
||||
common.OptionMap["HeaderNavModules"] = previous
|
||||
return
|
||||
}
|
||||
delete(common.OptionMap, "HeaderNavModules")
|
||||
})
|
||||
}
|
||||
|
||||
func performHeaderNavRequest(t *testing.T, handler gin.HandlerFunc, authenticated bool) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.Use(sessions.Sessions("session", cookie.NewStore([]byte("header-nav-test"))))
|
||||
router.GET("/login", func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("username", "tester")
|
||||
session.Set("role", common.RoleCommonUser)
|
||||
session.Set("id", 1)
|
||||
session.Set("status", common.UserStatusEnabled)
|
||||
session.Set("group", "default")
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
router.GET("/api/test", handler, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
})
|
||||
|
||||
var cookies []*http.Cookie
|
||||
if authenticated {
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
require.Equal(t, http.StatusNoContent, loginRecorder.Code)
|
||||
cookies = loginRecorder.Result().Cookies()
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/test", nil)
|
||||
if authenticated {
|
||||
request.Header.Set("New-Api-User", "1")
|
||||
for _, cookie := range cookies {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
router.ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsDisabledPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForPricing(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRequiresLoginForRankings(t *testing.T) {
|
||||
raw := `{"rankings":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModuleAuthRejectsLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"rankings":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModuleAuth("rankings"), false)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsDefaultPublicAccess(t *testing.T) {
|
||||
withHeaderNavModules(t, "")
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthAllowsLoggedInWhenDisabled(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":false,"requireAuth":false}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), true)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginWhenRequireAuth(t *testing.T) {
|
||||
raw := `{"pricing":{"enabled":true,"requireAuth":true}}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
|
||||
func TestHeaderNavModulePublicOrUserAuthRequiresLoginForLegacyDisabledModule(t *testing.T) {
|
||||
raw := `{"pricing":false}`
|
||||
withHeaderNavModules(t, raw)
|
||||
|
||||
recorder := performHeaderNavRequest(t, HeaderNavModulePublicOrUserAuth("pricing"), false)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func RelayPanicRecover() gin.HandlerFunc {
|
||||
common.SysLog(fmt.Sprintf("stacktrace from panic: %s", string(debug.Stack())))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": gin.H{
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://github.com/Calcium-Ion/new-api", err),
|
||||
"message": fmt.Sprintf("Panic detected, error: %v. Please submit a issue here: https://git.viaeon.com/admin/new-api/issues", err),
|
||||
"type": "new_api_panic",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AnonymousRequestBodyLimit() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
maxBytes := common.GetAnonymousRequestBodyLimitBytes()
|
||||
if maxBytes <= 0 || c.Request.Body == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
originalBody := c.Request.Body
|
||||
limitedBody, err := readAnonymousRequestBody(originalBody, maxBytes)
|
||||
_ = originalBody.Close()
|
||||
if err != nil {
|
||||
if common.IsRequestBodyTooLargeError(err) {
|
||||
c.AbortWithStatus(http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(limitedBody))
|
||||
c.Request.ContentLength = int64(len(limitedBody))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func readAnonymousRequestBody(body io.Reader, maxBytes int64) ([]byte, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(body, maxBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxBytes {
|
||||
return nil, common.ErrRequestBodyTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
+92
-44
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/samber/lo"
|
||||
@@ -128,6 +129,38 @@ func resolveChannelSortOptions(idSort bool, sortOptions []ChannelSortOptions) Ch
|
||||
return options
|
||||
}
|
||||
|
||||
func NormalizeChannelGroupFilter(group string) string {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" || strings.EqualFold(group, "all") || strings.EqualFold(group, "null") {
|
||||
return ""
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
func channelGroupFilterCondition() string {
|
||||
if common.UsingMySQL {
|
||||
return `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
return `(',' || ` + commonGroupCol + ` || ',') LIKE ? ESCAPE '!'`
|
||||
}
|
||||
|
||||
func channelGroupFilterPattern(group string) string {
|
||||
group = strings.NewReplacer(
|
||||
"!", "!!",
|
||||
"%", "!%",
|
||||
"_", "!_",
|
||||
).Replace(group)
|
||||
return "%," + group + ",%"
|
||||
}
|
||||
|
||||
func ApplyChannelGroupFilter(query *gorm.DB, group string) *gorm.DB {
|
||||
group = NormalizeChannelGroupFilter(group)
|
||||
if group == "" {
|
||||
return query
|
||||
}
|
||||
return query.Where(channelGroupFilterCondition(), channelGroupFilterPattern(group))
|
||||
}
|
||||
|
||||
// Value implements driver.Valuer interface
|
||||
func (c ChannelInfo) Value() (driver.Value, error) {
|
||||
return common.Marshal(&c)
|
||||
@@ -218,10 +251,9 @@ func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) {
|
||||
if err != nil {
|
||||
return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
//println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
defer func() {
|
||||
if common.DebugEnabled {
|
||||
println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex))
|
||||
logger.LogDebug(nil, "channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
if !common.MemoryCacheEnabled {
|
||||
_ = channel.SaveChannelInfo()
|
||||
@@ -365,25 +397,12 @@ func SearchChannels(keyword string, group string, model string, idSort bool, sor
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
// 执行查询
|
||||
err := order.Apply(baseQuery.Where(whereClause, args...)).Find(&channels).Error
|
||||
err := order.Apply(baseQuery).Find(&channels).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -401,9 +420,6 @@ func GetChannelById(id int, selectAll bool) (*Channel, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if channel == nil {
|
||||
return nil, errors.New("channel not found")
|
||||
}
|
||||
return channel, nil
|
||||
}
|
||||
|
||||
@@ -627,13 +643,25 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
if len(keys) == 0 {
|
||||
channel.Status = status
|
||||
} else {
|
||||
var keyIndex int
|
||||
keyIndex := -1
|
||||
for i, key := range keys {
|
||||
if key == usingKey {
|
||||
keyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyIndex < 0 {
|
||||
if usingKey != "" {
|
||||
common.SysLog(fmt.Sprintf("failed to update multi-key status: channel_id=%d, using key not found", channel.Id))
|
||||
return
|
||||
}
|
||||
channel.Status = status
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = reason
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
return
|
||||
}
|
||||
if channel.ChannelInfo.MultiKeyStatusList == nil {
|
||||
channel.ChannelInfo.MultiKeyStatusList = make(map[int]int)
|
||||
}
|
||||
@@ -650,16 +678,31 @@ func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason
|
||||
channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason
|
||||
channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp()
|
||||
}
|
||||
if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize {
|
||||
if !hasEnabledMultiKey(keys, channel.ChannelInfo.MultiKeyStatusList) {
|
||||
channel.Status = common.ChannelStatusAutoDisabled
|
||||
info := channel.GetOtherInfo()
|
||||
info["status_reason"] = "All keys are disabled"
|
||||
info["status_time"] = common.GetTimestamp()
|
||||
channel.SetOtherInfo(info)
|
||||
} else if status == common.ChannelStatusEnabled {
|
||||
channel.Status = common.ChannelStatusEnabled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hasEnabledMultiKey(keys []string, statusList map[int]int) bool {
|
||||
for i := range keys {
|
||||
if statusList == nil {
|
||||
return true
|
||||
}
|
||||
status, ok := statusList[i]
|
||||
if !ok || status == common.ChannelStatusEnabled {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool {
|
||||
if common.MemoryCacheEnabled {
|
||||
channelStatusLock.Lock()
|
||||
@@ -671,11 +714,15 @@ func UpdateChannelStatus(channelId int, usingKey string, status int, reason stri
|
||||
}
|
||||
if channelCache.ChannelInfo.IsMultiKey {
|
||||
// Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey
|
||||
beforeStatus := channelCache.Status
|
||||
pollingLock := GetChannelPollingLock(channelId)
|
||||
pollingLock.Lock()
|
||||
// 如果是多Key模式,更新缓存中的状态
|
||||
handlerMultiKeyUpdate(channelCache, usingKey, status, reason)
|
||||
pollingLock.Unlock()
|
||||
if beforeStatus != channelCache.Status {
|
||||
CacheUpdateChannelStatus(channelId, channelCache.Status)
|
||||
}
|
||||
//CacheUpdateChannel(channelCache)
|
||||
//return true
|
||||
} else {
|
||||
@@ -758,7 +805,7 @@ func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *
|
||||
updateData.Tag = newTag
|
||||
updatedTag = *newTag
|
||||
}
|
||||
if modelMapping != nil && *modelMapping != "" {
|
||||
if modelMapping != nil {
|
||||
updateData.ModelMapping = modelMapping
|
||||
}
|
||||
if models != nil && *models != "" {
|
||||
@@ -831,8 +878,18 @@ func DeleteDisabledChannel() (int64, error) {
|
||||
}
|
||||
|
||||
func GetPaginatedTags(offset int, limit int) ([]*string, error) {
|
||||
return GetPaginatedChannelTags(DB.Model(&Channel{}), offset, limit)
|
||||
}
|
||||
|
||||
func GetPaginatedChannelTags(query *gorm.DB, offset int, limit int) ([]*string, error) {
|
||||
var tags []*string
|
||||
err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error
|
||||
err := query.
|
||||
Select("DISTINCT tag").
|
||||
Where("tag is not null AND tag != ''").
|
||||
Order(clause.OrderByColumn{Column: clause.Column{Name: "tag"}}).
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Find(&tags).Error
|
||||
return tags, err
|
||||
}
|
||||
|
||||
@@ -860,24 +917,11 @@ func SearchTags(keyword string, group string, model string, idSort bool) ([]*str
|
||||
baseQuery := DB.Model(&Channel{}).Omit("key")
|
||||
|
||||
// 构造WHERE子句
|
||||
var whereClause string
|
||||
var args []interface{}
|
||||
if group != "" && group != "null" {
|
||||
var groupCondition string
|
||||
if common.UsingMySQL {
|
||||
groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?`
|
||||
} else {
|
||||
// sqlite, PostgreSQL
|
||||
groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?`
|
||||
}
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%")
|
||||
} else {
|
||||
whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%")
|
||||
}
|
||||
whereClause := "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?"
|
||||
args := []any{common.String2Int(keyword), "%" + keyword + "%", keyword, "%" + keyword + "%", "%" + model + "%"}
|
||||
baseQuery = ApplyChannelGroupFilter(baseQuery.Where(whereClause, args...), group)
|
||||
|
||||
subQuery := baseQuery.Where(whereClause, args...).
|
||||
subQuery := baseQuery.
|
||||
Select("tag").
|
||||
Where("tag != ''").
|
||||
Order(order)
|
||||
@@ -1018,8 +1062,12 @@ func CountAllChannels() (int64, error) {
|
||||
|
||||
// CountAllTags returns number of non-empty distinct tags
|
||||
func CountAllTags() (int64, error) {
|
||||
return CountChannelTags(DB.Model(&Channel{}))
|
||||
}
|
||||
|
||||
func CountChannelTags(query *gorm.DB) (int64, error) {
|
||||
var total int64
|
||||
err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
err := query.Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
)
|
||||
|
||||
@@ -257,9 +258,12 @@ func CacheUpdateChannel(channel *Channel) {
|
||||
return
|
||||
}
|
||||
|
||||
println("CacheUpdateChannel:", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
|
||||
println("before:", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
if channelsIDM == nil {
|
||||
channelsIDM = make(map[int]*Channel)
|
||||
}
|
||||
if oldChannel, ok := channelsIDM[channel.Id]; ok {
|
||||
logger.LogDebug(nil, "CacheUpdateChannel before: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, oldChannel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
channelsIDM[channel.Id] = channel
|
||||
println("after :", channelsIDM[channel.Id].ChannelInfo.MultiKeyPollingIndex)
|
||||
logger.LogDebug(nil, "CacheUpdateChannel after: id=%d, name=%s, status=%d, polling_index=%d", channel.Id, channel.Name, channel.Status, channel.ChannelInfo.MultiKeyPollingIndex)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Document struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
CategoryId *int `json:"category_id" gorm:"index"`
|
||||
Visibility string `json:"visibility" gorm:"default:'public'"` // public, auth, admin
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
AuthorId int `json:"author_id" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func GetDocuments(keyword string, visibility string, categoryId *int, startIdx int, num int) ([]*Document, int64, error) {
|
||||
query := DB.Model(&Document{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("title LIKE ? OR content LIKE ?", like, like)
|
||||
}
|
||||
if visibility != "" {
|
||||
query = query.Where("visibility = ?", visibility)
|
||||
}
|
||||
if categoryId != nil {
|
||||
query = query.Where("category_id = ?", *categoryId)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var documents []*Document
|
||||
if err := query.Order("sort_order ASC, id DESC").Offset(startIdx).Limit(num).Find(&documents).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return documents, total, nil
|
||||
}
|
||||
|
||||
func GetDocumentsByVisibility(keyword string, visibilities []string, categoryId *int, startIdx int, num int) ([]*Document, int64, error) {
|
||||
query := DB.Model(&Document{})
|
||||
if keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
query = query.Where("title LIKE ? OR content LIKE ?", like, like)
|
||||
}
|
||||
if len(visibilities) > 0 {
|
||||
query = query.Where("visibility IN ?", visibilities)
|
||||
}
|
||||
if categoryId != nil {
|
||||
query = query.Where("category_id = ?", *categoryId)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var documents []*Document
|
||||
if err := query.Order("sort_order ASC, id DESC").Offset(startIdx).Limit(num).Find(&documents).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return documents, total, nil
|
||||
}
|
||||
|
||||
func GetDocumentBySlug(slug string) (*Document, error) {
|
||||
var doc Document
|
||||
err := DB.Where("slug = ?", slug).First(&doc).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func GetDocumentById(id int) (*Document, error) {
|
||||
var doc Document
|
||||
err := DB.First(&doc, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func CreateDocument(doc *Document) error {
|
||||
return DB.Create(doc).Error
|
||||
}
|
||||
|
||||
func UpdateDocument(doc *Document) error {
|
||||
return DB.Model(doc).Select("title", "slug", "content", "category_id", "visibility", "sort_order").Updates(doc).Error
|
||||
}
|
||||
|
||||
func DeleteDocument(id int) error {
|
||||
// Delete associated versions first
|
||||
DB.Where("document_id = ?", id).Delete(&DocumentVersion{})
|
||||
return DB.Delete(&Document{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentCategory struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Slug string `json:"slug" gorm:"type:varchar(255);uniqueIndex;not null"`
|
||||
ParentId *int `json:"parent_id" gorm:"index"`
|
||||
SortOrder int `json:"sort_order" gorm:"default:0"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func GetDocumentCategories() ([]*DocumentCategory, error) {
|
||||
var categories []*DocumentCategory
|
||||
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func GetDocumentCategoryTree() ([]*DocumentCategory, error) {
|
||||
var categories []*DocumentCategory
|
||||
err := DB.Order("sort_order ASC, id ASC").Find(&categories).Error
|
||||
return categories, err
|
||||
}
|
||||
|
||||
func CreateDocumentCategory(category *DocumentCategory) error {
|
||||
return DB.Create(category).Error
|
||||
}
|
||||
|
||||
func UpdateDocumentCategory(category *DocumentCategory) error {
|
||||
return DB.Model(category).Select("name", "slug", "parent_id", "sort_order").Updates(category).Error
|
||||
}
|
||||
|
||||
func DeleteDocumentCategory(id int) error {
|
||||
return DB.Delete(&DocumentCategory{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type DocumentVersion struct {
|
||||
Id int `json:"id" gorm:"primaryKey"`
|
||||
DocumentId int `json:"document_id" gorm:"index;not null"`
|
||||
Content string `json:"content" gorm:"type:text;not null"`
|
||||
AuthorId int `json:"author_id" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func GetDocumentVersions(documentId int, startIdx int, num int) ([]*DocumentVersion, int64, error) {
|
||||
query := DB.Model(&DocumentVersion{}).Where("document_id = ?", documentId)
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var versions []*DocumentVersion
|
||||
if err := query.Order("id DESC").Offset(startIdx).Limit(num).Find(&versions).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return versions, total, nil
|
||||
}
|
||||
|
||||
func CreateDocumentVersion(version *DocumentVersion) error {
|
||||
return DB.Create(version).Error
|
||||
}
|
||||
+70
-48
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -16,27 +17,42 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func applyExplicitLogTextFilter(tx *gorm.DB, column string, value string) (*gorm.DB, error) {
|
||||
if value == "" {
|
||||
return tx, nil
|
||||
}
|
||||
if strings.Contains(value, "%") {
|
||||
pattern, err := sanitizeLikePattern(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tx.Where(column+" LIKE ? ESCAPE '!'", pattern), nil
|
||||
}
|
||||
return tx.Where(column+" = ?", value), nil
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:1;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:2;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
Id int `json:"id" gorm:"index:idx_created_at_id,priority:2;index:idx_user_id_id,priority:2"`
|
||||
UserId int `json:"user_id" gorm:"index;index:idx_user_id_id,priority:1"`
|
||||
CreatedAt int64 `json:"created_at" gorm:"bigint;index:idx_created_at_id,priority:1;index:idx_created_at_type"`
|
||||
Type int `json:"type" gorm:"index:idx_created_at_type"`
|
||||
Content string `json:"content"`
|
||||
Username string `json:"username" gorm:"index;index:index_username_model_name,priority:2;default:''"`
|
||||
TokenName string `json:"token_name" gorm:"index;default:''"`
|
||||
ModelName string `json:"model_name" gorm:"index;index:index_username_model_name,priority:1;default:''"`
|
||||
Quota int `json:"quota" gorm:"default:0"`
|
||||
PromptTokens int `json:"prompt_tokens" gorm:"default:0"`
|
||||
CompletionTokens int `json:"completion_tokens" gorm:"default:0"`
|
||||
UseTime int `json:"use_time" gorm:"default:0"`
|
||||
IsStream bool `json:"is_stream"`
|
||||
ChannelId int `json:"channel" gorm:"index"`
|
||||
ChannelName string `json:"channel_name" gorm:"->"`
|
||||
TokenId int `json:"token_id" gorm:"default:0;index"`
|
||||
Group string `json:"group" gorm:"index"`
|
||||
Ip string `json:"ip" gorm:"index;default:''"`
|
||||
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
|
||||
UpstreamRequestId string `json:"upstream_request_id,omitempty" gorm:"type:varchar(128);index:idx_logs_upstream_request_id;default:''"`
|
||||
Other string `json:"other"`
|
||||
}
|
||||
|
||||
// don't use iota, avoid change log type value
|
||||
@@ -144,9 +160,10 @@ func RecordTopupLog(userId int, content string, callerIp string, paymentMethod s
|
||||
|
||||
func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string, tokenName string, content string, tokenId int, useTimeSeconds int,
|
||||
isStream bool, group string, other map[string]interface{}) {
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
|
||||
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, common.LocalLogPreview(content)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
otherStr := common.MapToJsonStr(other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -177,8 +194,9 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
UpstreamRequestId: upstreamRequestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -208,6 +226,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
|
||||
username := c.GetString("username")
|
||||
requestId := c.GetString(common.RequestIdKey)
|
||||
upstreamRequestId := c.GetString(common.UpstreamRequestIdKey)
|
||||
otherStr := common.MapToJsonStr(params.Other)
|
||||
// 判断是否需要记录 IP
|
||||
needRecordIp := false
|
||||
@@ -238,8 +257,9 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
|
||||
}
|
||||
return ""
|
||||
}(),
|
||||
RequestId: requestId,
|
||||
Other: otherStr,
|
||||
RequestId: requestId,
|
||||
UpstreamRequestId: upstreamRequestId,
|
||||
Other: otherStr,
|
||||
}
|
||||
err := LOG_DB.Create(log).Error
|
||||
if err != nil {
|
||||
@@ -295,7 +315,7 @@ func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
|
||||
}
|
||||
}
|
||||
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB
|
||||
@@ -303,11 +323,11 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
tx = LOG_DB.Where("logs.type = ?", logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
tx = tx.Where("logs.model_name like ?", modelName)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if username != "" {
|
||||
tx = tx.Where("logs.username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.username", username); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -315,6 +335,9 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if upstreamRequestId != "" {
|
||||
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
@@ -331,7 +354,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
err = tx.Order("logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
err = tx.Order("logs.created_at desc, logs.id desc").Limit(num).Offset(startIdx).Find(&logs).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -381,7 +404,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
|
||||
|
||||
const logSearchCountLimit = 10000
|
||||
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
|
||||
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string, upstreamRequestId string) (logs []*Log, total int64, err error) {
|
||||
var tx *gorm.DB
|
||||
if logType == LogTypeUnknown {
|
||||
tx = LOG_DB.Where("logs.user_id = ?", userId)
|
||||
@@ -389,12 +412,8 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
tx = LOG_DB.Where("logs.user_id = ? and logs.type = ?", userId, logType)
|
||||
}
|
||||
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
tx = tx.Where("logs.model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "logs.model_name", modelName); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("logs.token_name = ?", tokenName)
|
||||
@@ -402,6 +421,9 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
|
||||
if requestId != "" {
|
||||
tx = tx.Where("logs.request_id = ?", requestId)
|
||||
}
|
||||
if upstreamRequestId != "" {
|
||||
tx = tx.Where("logs.upstream_request_id = ?", upstreamRequestId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("logs.created_at >= ?", startTimestamp)
|
||||
}
|
||||
@@ -438,9 +460,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
// 为rpm和tpm创建单独的查询
|
||||
rpmTpmQuery := LOG_DB.Table("logs").Select("count(*) rpm, sum(prompt_tokens) + sum(completion_tokens) tpm")
|
||||
|
||||
if username != "" {
|
||||
tx = tx.Where("username = ?", username)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("username = ?", username)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "username", username); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if tokenName != "" {
|
||||
tx = tx.Where("token_name = ?", tokenName)
|
||||
@@ -452,13 +476,11 @@ func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelNa
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
if modelName != "" {
|
||||
modelNamePattern, err := sanitizeLikePattern(modelName)
|
||||
if err != nil {
|
||||
return stat, err
|
||||
}
|
||||
tx = tx.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
rpmTpmQuery = rpmTpmQuery.Where("model_name LIKE ? ESCAPE '!'", modelNamePattern)
|
||||
if tx, err = applyExplicitLogTextFilter(tx, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if rpmTpmQuery, err = applyExplicitLogTextFilter(rpmTpmQuery, "model_name", modelName); err != nil {
|
||||
return stat, err
|
||||
}
|
||||
if channel != 0 {
|
||||
tx = tx.Where("channel_id = ?", channel)
|
||||
|
||||
@@ -281,6 +281,9 @@ func migrateDB() error {
|
||||
&CustomOAuthProvider{},
|
||||
&UserOAuthBinding{},
|
||||
&PerfMetric{},
|
||||
&DocumentCategory{},
|
||||
&Document{},
|
||||
&DocumentVersion{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -330,6 +333,9 @@ func migrateDBFast() error {
|
||||
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
|
||||
{&UserOAuthBinding{}, "UserOAuthBinding"},
|
||||
{&PerfMetric{}, "PerfMetric"},
|
||||
{&DocumentCategory{}, "DocumentCategory"},
|
||||
{&Document{}, "Document"},
|
||||
{&DocumentVersion{}, "DocumentVersion"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
@@ -397,8 +403,10 @@ func ensureSubscriptionPlanTableSQLite() error {
|
||||
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
|
||||
` + "`enabled`" + ` numeric DEFAULT 1,
|
||||
` + "`sort_order`" + ` integer DEFAULT 0,
|
||||
` + "`allow_balance_pay`" + ` numeric DEFAULT 1,
|
||||
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`waffo_pancake_product_id`" + ` varchar(128) DEFAULT '',
|
||||
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
|
||||
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
|
||||
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
|
||||
@@ -430,8 +438,10 @@ PRIMARY KEY (` + "`id`" + `)
|
||||
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
|
||||
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
|
||||
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
|
||||
{Name: "allow_balance_pay", DDL: "`allow_balance_pay` numeric DEFAULT 1"},
|
||||
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "waffo_pancake_product_id", DDL: "`waffo_pancake_product_id` varchar(128) DEFAULT ''"},
|
||||
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
|
||||
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
|
||||
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
|
||||
|
||||
@@ -2,6 +2,7 @@ package model
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
|
||||
@@ -135,6 +136,62 @@ func GetBoundChannelsByModelsMap(modelNames []string) (map[string][]BoundChannel
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeLookupValues(values []string) []string {
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func GetPreferredModelOwnerChannelTypes(modelNames []string, groups []string) (map[string]int, error) {
|
||||
result := make(map[string]int)
|
||||
modelNames = normalizeLookupValues(modelNames)
|
||||
if len(modelNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Model string
|
||||
ChannelType int
|
||||
}
|
||||
var rows []row
|
||||
|
||||
query := DB.Table("abilities").
|
||||
Select("abilities.model as model, channels.type as channel_type").
|
||||
Joins("JOIN channels ON abilities.channel_id = channels.id").
|
||||
Where("abilities.model IN ? AND abilities.enabled = ? AND channels.status = ?", modelNames, true, common.ChannelStatusEnabled).
|
||||
Order("COALESCE(abilities.priority, 0) DESC").
|
||||
Order("abilities.weight DESC").
|
||||
Order("abilities.channel_id ASC")
|
||||
|
||||
groups = normalizeLookupValues(groups)
|
||||
if len(groups) > 0 {
|
||||
query = query.Where("abilities."+commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
if _, ok := result[r.Model]; ok {
|
||||
continue
|
||||
}
|
||||
result[r.Model] = r.ChannelType
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func SearchModels(keyword string, vendor string, offset int, limit int) ([]*Model, int64, error) {
|
||||
var models []*Model
|
||||
db := DB.Model(&Model{})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func clearPreferredOwnerTables(t *testing.T) {
|
||||
t.Helper()
|
||||
require.NoError(t, DB.Exec("DELETE FROM abilities").Error)
|
||||
require.NoError(t, DB.Exec("DELETE FROM channels").Error)
|
||||
}
|
||||
|
||||
func insertPreferredOwnerCandidate(
|
||||
t *testing.T,
|
||||
channelID int,
|
||||
modelName string,
|
||||
group string,
|
||||
channelType int,
|
||||
priority int64,
|
||||
weight uint,
|
||||
channelStatus int,
|
||||
abilityEnabled bool,
|
||||
) {
|
||||
t.Helper()
|
||||
require.NoError(t, DB.Create(&Channel{
|
||||
Id: channelID,
|
||||
Type: channelType,
|
||||
Key: fmt.Sprintf("key-%d", channelID),
|
||||
Status: channelStatus,
|
||||
Name: fmt.Sprintf("channel-%d", channelID),
|
||||
}).Error)
|
||||
require.NoError(t, DB.Create(&Ability{
|
||||
Group: group,
|
||||
Model: modelName,
|
||||
ChannelId: channelID,
|
||||
Enabled: abilityEnabled,
|
||||
Priority: &priority,
|
||||
Weight: weight,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
func TestGetPreferredModelOwnerChannelTypes(t *testing.T) {
|
||||
const modelName = "gpt-5.4"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(t *testing.T)
|
||||
groups []string
|
||||
expected int
|
||||
found bool
|
||||
}{
|
||||
{
|
||||
name: "openai only",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 0, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "codex only",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 0, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "priority wins",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 100, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 2, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "weight wins when priority is equal",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 20, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeCodex,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "channel id stabilizes exact ties",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeCodex, 1, 10, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeOpenAI, 1, 10, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "group filter excludes other groups",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "vip", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, true)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusEnabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
expected: constant.ChannelTypeOpenAI,
|
||||
found: true,
|
||||
},
|
||||
{
|
||||
name: "disabled candidates are ignored",
|
||||
setup: func(t *testing.T) {
|
||||
insertPreferredOwnerCandidate(t, 1, modelName, "default", constant.ChannelTypeCodex, 10, 100, common.ChannelStatusEnabled, false)
|
||||
insertPreferredOwnerCandidate(t, 2, modelName, "default", constant.ChannelTypeOpenAI, 1, 0, common.ChannelStatusManuallyDisabled, true)
|
||||
},
|
||||
groups: []string{"default"},
|
||||
found: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clearPreferredOwnerTables(t)
|
||||
tt.setup(t)
|
||||
|
||||
owners, err := GetPreferredModelOwnerChannelTypes([]string{modelName}, tt.groups)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, ok := owners[modelName]
|
||||
require.Equal(t, tt.found, ok)
|
||||
if tt.found {
|
||||
require.Equal(t, tt.expected, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+38
-19
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/setting/performance_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Option struct {
|
||||
@@ -106,18 +107,13 @@ func InitOptionMap() {
|
||||
common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp)
|
||||
common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString()
|
||||
common.OptionMap["WaffoPancakeEnabled"] = strconv.FormatBool(setting.WaffoPancakeEnabled)
|
||||
common.OptionMap["WaffoPancakeSandbox"] = strconv.FormatBool(setting.WaffoPancakeSandbox)
|
||||
common.OptionMap["WaffoPancakeMerchantID"] = setting.WaffoPancakeMerchantID
|
||||
common.OptionMap["WaffoPancakePrivateKey"] = setting.WaffoPancakePrivateKey
|
||||
common.OptionMap["WaffoPancakeWebhookPublicKey"] = setting.WaffoPancakeWebhookPublicKey
|
||||
common.OptionMap["WaffoPancakeWebhookTestKey"] = setting.WaffoPancakeWebhookTestKey
|
||||
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
|
||||
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
|
||||
common.OptionMap["WaffoPancakeReturnURL"] = setting.WaffoPancakeReturnURL
|
||||
common.OptionMap["WaffoPancakeCurrency"] = setting.WaffoPancakeCurrency
|
||||
common.OptionMap["WaffoPancakeUnitPrice"] = strconv.FormatFloat(setting.WaffoPancakeUnitPrice, 'f', -1, 64)
|
||||
common.OptionMap["WaffoPancakeMinTopUp"] = strconv.Itoa(setting.WaffoPancakeMinTopUp)
|
||||
common.OptionMap["WaffoPancakeStoreID"] = setting.WaffoPancakeStoreID
|
||||
common.OptionMap["WaffoPancakeProductID"] = setting.WaffoPancakeProductID
|
||||
common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString()
|
||||
common.OptionMap["Chats"] = setting.Chats2JsonString()
|
||||
common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString()
|
||||
@@ -222,6 +218,39 @@ func UpdateOption(key string, value string) error {
|
||||
return updateOptionMap(key, value)
|
||||
}
|
||||
|
||||
// UpdateOptionsBulk persists multiple key/value pairs in a single database
|
||||
// transaction, then dispatches them through updateOptionMap in one pass. If
|
||||
// any DB write fails the whole transaction rolls back and no in-memory state
|
||||
// is touched — safe for callers that must commit a set of related options
|
||||
// atomically (e.g. payment gateway binding).
|
||||
func UpdateOptionsBulk(values map[string]string) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
for k, v := range values {
|
||||
option := Option{Key: k}
|
||||
if err := tx.FirstOrCreate(&option, Option{Key: k}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
option.Value = v
|
||||
if err := tx.Save(&option).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for k, v := range values {
|
||||
if err := updateOptionMap(k, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateOptionMap(key string, value string) (err error) {
|
||||
common.OptionMapRWMutex.Lock()
|
||||
defer common.OptionMapRWMutex.Unlock()
|
||||
@@ -419,26 +448,16 @@ func updateOptionMap(key string, value string) (err error) {
|
||||
setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoMinTopUp":
|
||||
setting.WaffoMinTopUp, _ = strconv.Atoi(value)
|
||||
case "WaffoPancakeEnabled":
|
||||
setting.WaffoPancakeEnabled = value == "true"
|
||||
case "WaffoPancakeSandbox":
|
||||
setting.WaffoPancakeSandbox = value == "true"
|
||||
case "WaffoPancakeMerchantID":
|
||||
setting.WaffoPancakeMerchantID = value
|
||||
case "WaffoPancakePrivateKey":
|
||||
setting.WaffoPancakePrivateKey = value
|
||||
case "WaffoPancakeWebhookPublicKey":
|
||||
setting.WaffoPancakeWebhookPublicKey = value
|
||||
case "WaffoPancakeWebhookTestKey":
|
||||
setting.WaffoPancakeWebhookTestKey = value
|
||||
case "WaffoPancakeReturnURL":
|
||||
setting.WaffoPancakeReturnURL = value
|
||||
case "WaffoPancakeStoreID":
|
||||
setting.WaffoPancakeStoreID = value
|
||||
case "WaffoPancakeProductID":
|
||||
setting.WaffoPancakeProductID = value
|
||||
case "WaffoPancakeReturnURL":
|
||||
setting.WaffoPancakeReturnURL = value
|
||||
case "WaffoPancakeCurrency":
|
||||
setting.WaffoPancakeCurrency = value
|
||||
case "WaffoPancakeUnitPrice":
|
||||
setting.WaffoPancakeUnitPrice, _ = strconv.ParseFloat(value, 64)
|
||||
case "WaffoPancakeMinTopUp":
|
||||
|
||||
+17
-10
@@ -37,13 +37,13 @@ func UpsertPerfMetric(metric *PerfMetric) error {
|
||||
{Name: "bucket_ts"},
|
||||
},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"request_count": gorm.Expr("request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("generation_ms + ?", metric.GenerationMs),
|
||||
"request_count": gorm.Expr("perf_metrics.request_count + ?", metric.RequestCount),
|
||||
"success_count": gorm.Expr("perf_metrics.success_count + ?", metric.SuccessCount),
|
||||
"total_latency_ms": gorm.Expr("perf_metrics.total_latency_ms + ?", metric.TotalLatencyMs),
|
||||
"ttft_sum_ms": gorm.Expr("perf_metrics.ttft_sum_ms + ?", metric.TtftSumMs),
|
||||
"ttft_count": gorm.Expr("perf_metrics.ttft_count + ?", metric.TtftCount),
|
||||
"output_tokens": gorm.Expr("perf_metrics.output_tokens + ?", metric.OutputTokens),
|
||||
"generation_ms": gorm.Expr("perf_metrics.generation_ms + ?", metric.GenerationMs),
|
||||
}),
|
||||
}).Create(metric).Error
|
||||
}
|
||||
@@ -68,11 +68,18 @@ type PerfMetricSummary struct {
|
||||
GenerationMs int64 `json:"generation_ms"`
|
||||
}
|
||||
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64) ([]PerfMetricSummary, error) {
|
||||
func GetPerfMetricsSummaryAll(startTs int64, endTs int64, groups []string) ([]PerfMetricSummary, error) {
|
||||
var summaries []PerfMetricSummary
|
||||
err := DB.Model(&PerfMetric{}).
|
||||
query := DB.Model(&PerfMetric{}).
|
||||
Select("model_name, SUM(request_count) as request_count, SUM(success_count) as success_count, SUM(total_latency_ms) as total_latency_ms, SUM(output_tokens) as output_tokens, SUM(generation_ms) as generation_ms").
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs).
|
||||
Where("bucket_ts >= ? AND bucket_ts <= ?", startTs, endTs)
|
||||
if groups != nil {
|
||||
if len(groups) == 0 {
|
||||
return summaries, nil
|
||||
}
|
||||
query = query.Where(commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
err := query.
|
||||
Group("model_name").
|
||||
Having("SUM(request_count) > 0").
|
||||
Find(&summaries).Error
|
||||
|
||||
+117
-2
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/samber/hot"
|
||||
"github.com/shopspring/decimal"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -159,8 +160,11 @@ type SubscriptionPlan struct {
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
AllowBalancePay *bool `json:"allow_balance_pay" gorm:"default:true"`
|
||||
|
||||
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||
WaffoPancakeProductId string `json:"waffo_pancake_product_id" gorm:"type:varchar(128);default:''"`
|
||||
|
||||
// Max purchases per user (0 = unlimited)
|
||||
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||
@@ -191,6 +195,12 @@ func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *SubscriptionPlan) NormalizeDefaults() {
|
||||
if p.AllowBalancePay == nil {
|
||||
p.AllowBalancePay = common.GetPointer(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||
type SubscriptionOrder struct {
|
||||
Id int `json:"id"`
|
||||
@@ -358,6 +368,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
key := subscriptionPlanCacheKey(id)
|
||||
if key != "" {
|
||||
if cached, found, err := getSubscriptionPlanCache().Get(key); err == nil && found {
|
||||
cached.NormalizeDefaults()
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
@@ -369,6 +380,7 @@ func getSubscriptionPlanByIdTx(tx *gorm.DB, id int) (*SubscriptionPlan, error) {
|
||||
if err := query.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plan.NormalizeDefaults()
|
||||
_ = getSubscriptionPlanCache().SetWithTTL(key, plan, subscriptionPlanCacheTTL())
|
||||
return &plan, nil
|
||||
}
|
||||
@@ -664,6 +676,109 @@ func AdminBindSubscription(userId int, planId int, sourceNote string) (string, e
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func calcSubscriptionBalanceQuota(priceAmount float64) (int, error) {
|
||||
if priceAmount <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
if common.QuotaPerUnit <= 0 {
|
||||
return 0, errors.New("额度单位配置错误")
|
||||
}
|
||||
quota := decimal.NewFromFloat(priceAmount).
|
||||
Mul(decimal.NewFromFloat(common.QuotaPerUnit)).
|
||||
Ceil().
|
||||
IntPart()
|
||||
return int(quota), nil
|
||||
}
|
||||
|
||||
// PurchaseSubscriptionWithBalance creates a subscription by deducting the user's wallet quota.
|
||||
func PurchaseSubscriptionWithBalance(userId int, planId int) error {
|
||||
if userId <= 0 || planId <= 0 {
|
||||
return errors.New("invalid userId or planId")
|
||||
}
|
||||
|
||||
var logPlanTitle string
|
||||
var logMoney float64
|
||||
var chargedQuota int
|
||||
var upgradeGroup string
|
||||
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||
plan, err := getSubscriptionPlanByIdTx(tx, planId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !plan.Enabled {
|
||||
return errors.New("套餐未启用")
|
||||
}
|
||||
if plan.PriceAmount < 0 {
|
||||
return errors.New("套餐价格不能为负数")
|
||||
}
|
||||
if plan.AllowBalancePay != nil && !*plan.AllowBalancePay {
|
||||
return errors.New("该套餐不允许使用余额兑换")
|
||||
}
|
||||
|
||||
requiredQuota, err := calcSubscriptionBalanceQuota(plan.PriceAmount)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var user User
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", userId).First(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if requiredQuota > 0 && user.Quota < requiredQuota {
|
||||
return errors.New("余额不足")
|
||||
}
|
||||
if requiredQuota > 0 {
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).
|
||||
Update("quota", gorm.Expr("quota - ?", requiredQuota)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, PaymentMethodBalance); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := common.GetTimestamp()
|
||||
tradeNo := fmt.Sprintf("SUBBALUSR%dNO%s%d", userId, common.GetRandomString(6), time.Now().UnixNano())
|
||||
order := &SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: PaymentMethodBalance,
|
||||
PaymentProvider: PaymentProviderBalance,
|
||||
Status: common.TopUpStatusSuccess,
|
||||
CreateTime: now,
|
||||
CompleteTime: now,
|
||||
ProviderPayload: fmt.Sprintf("charged_quota=%d", requiredQuota),
|
||||
}
|
||||
if err := tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logPlanTitle = plan.Title
|
||||
logMoney = plan.PriceAmount
|
||||
chargedQuota = requiredQuota
|
||||
upgradeGroup = strings.TrimSpace(plan.UpgradeGroup)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chargedQuota > 0 {
|
||||
if err := cacheDecrUserQuota(userId, int64(chargedQuota)); err != nil {
|
||||
common.SysLog("failed to decrease user quota cache after subscription balance purchase: " + err.Error())
|
||||
}
|
||||
}
|
||||
if upgradeGroup != "" {
|
||||
_ = UpdateUserGroupCache(userId, upgradeGroup)
|
||||
}
|
||||
msg := fmt.Sprintf("使用余额购买订阅成功,套餐: %s,支付金额: %.2f,扣除额度: %d", logPlanTitle, logMoney, chargedQuota)
|
||||
RecordLog(userId, LogTypeTopup, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestMain(m *testing.M) {
|
||||
common.RedisEnabled = false
|
||||
common.BatchUpdateEnabled = false
|
||||
common.LogConsumeEnabled = true
|
||||
initCol()
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
@@ -39,10 +40,12 @@ func TestMain(m *testing.M) {
|
||||
&Token{},
|
||||
&Log{},
|
||||
&Channel{},
|
||||
&Ability{},
|
||||
&TopUp{},
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&PerfMetric{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
@@ -58,10 +61,12 @@ func truncateTables(t *testing.T) {
|
||||
DB.Exec("DELETE FROM tokens")
|
||||
DB.Exec("DELETE FROM logs")
|
||||
DB.Exec("DELETE FROM channels")
|
||||
DB.Exec("DELETE FROM abilities")
|
||||
DB.Exec("DELETE FROM top_ups")
|
||||
DB.Exec("DELETE FROM subscription_orders")
|
||||
DB.Exec("DELETE FROM subscription_plans")
|
||||
DB.Exec("DELETE FROM user_subscriptions")
|
||||
DB.Exec("DELETE FROM perf_metrics")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
PaymentMethodCreem = "creem"
|
||||
PaymentMethodWaffo = "waffo"
|
||||
PaymentMethodWaffoPancake = "waffo_pancake"
|
||||
PaymentMethodBalance = "balance"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
PaymentProviderCreem = "creem"
|
||||
PaymentProviderWaffo = "waffo"
|
||||
PaymentProviderWaffoPancake = "waffo_pancake"
|
||||
PaymentProviderBalance = "balance"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
+33
-19
@@ -34,8 +34,8 @@ type User struct {
|
||||
OidcId string `json:"oidc_id" gorm:"column:oidc_id;index"`
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
TelegramId string `json:"telegram_id" gorm:"column:telegram_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
AccessToken *string `json:"-" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
UsedQuota int `json:"used_quota" gorm:"type:int;default:0;column:used_quota"` // used quota
|
||||
RequestCount int `json:"request_count" gorm:"type:int;default:0;"` // request number
|
||||
@@ -224,7 +224,7 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) {
|
||||
func SearchUsers(keyword string, group string, role *int, status *int, startIdx int, num int) ([]*User, int64, error) {
|
||||
var users []*User
|
||||
var total int64
|
||||
var err error
|
||||
@@ -245,28 +245,25 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
|
||||
|
||||
// 构建搜索条件
|
||||
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
|
||||
likeArgs := []interface{}{"%" + keyword + "%", "%" + keyword + "%", "%" + keyword + "%"}
|
||||
|
||||
// 尝试将关键字转换为整数ID
|
||||
keywordInt, err := strconv.Atoi(keyword)
|
||||
if err == nil {
|
||||
// 如果是数字,同时搜索ID和其他字段
|
||||
likeCondition = "id = ? OR " + likeCondition
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
keywordInt, "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
} else {
|
||||
// 非数字关键字,只搜索字符串字段
|
||||
if group != "" {
|
||||
query = query.Where("("+likeCondition+") AND "+commonGroupCol+" = ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", group)
|
||||
} else {
|
||||
query = query.Where(likeCondition,
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
likeArgs = append([]interface{}{keywordInt}, likeArgs...)
|
||||
}
|
||||
|
||||
query = query.Where("("+likeCondition+")", likeArgs...)
|
||||
if group != "" {
|
||||
query = query.Where(commonGroupCol+" = ?", group)
|
||||
}
|
||||
if role != nil {
|
||||
query = query.Where("role = ?", *role)
|
||||
}
|
||||
if status != nil {
|
||||
query = query.Where("status = ?", *status)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
@@ -986,6 +983,23 @@ func updateUserUsedQuotaAndRequestCount(id int, quota int, count int) {
|
||||
//}
|
||||
}
|
||||
|
||||
func updateUserQuotaUsedQuotaAndRequestCount(id int, quota int, usedQuota int, requestCount int) {
|
||||
if quota == 0 && usedQuota == 0 && requestCount == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
"quota": gorm.Expr("quota + ?", quota),
|
||||
"used_quota": gorm.Expr("used_quota + ?", usedQuota),
|
||||
"request_count": gorm.Expr("request_count + ?", requestCount),
|
||||
},
|
||||
).Error
|
||||
if err != nil {
|
||||
common.SysLog("failed to batch update user quota, used quota and request count: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func updateUserUsedQuota(id int, quota int) {
|
||||
err := DB.Model(&User{}).Where("id = ?", id).Updates(
|
||||
map[string]interface{}{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user