diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 000000000000..dd466c5a4cd3 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,294 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + - develop + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ========== 代码检查 ========== + lint: + name: Code Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Run Go Lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install frontend dependencies + working-directory: ./web + run: bun install + + - name: Run frontend lint + working-directory: ./web + run: bun run lint || true + + # ========== 单元测试 ========== + test: + name: Unit Tests + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + cache: true + + - name: Run Go tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + + # ========== 构建镜像 ========== + build: + name: Build Docker Image + runs-on: ubuntu-latest + needs: [lint, test] + permissions: + contents: read + packages: write + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + image_digest: ${{ steps.build.outputs.digest }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=,suffix=,format=short + + - name: Build and push + id: build + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + # ========== 部署到开发环境 ========== + deploy-dev: + name: Deploy to Dev + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/develop' + environment: + name: development + url: http://dev.new-api.example.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.28.0' + + - name: Configure ACK credentials + uses: aliyun/ack-set-context@v1 + with: + access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }} + cluster-id: ${{ secrets.ACK_CLUSTER_ID_DEV }} + + - name: Update image tag + working-directory: ./deploy/aliyun/environments/dev + run: | + IMAGE_TAG=$(echo "${{ needs.build.outputs.image_tag }}" | cut -d',' -f1) + sed -i "s|image: calciumion/new-api:.*|image: ${IMAGE_TAG}|" 04-deployment.yaml + + - name: Deploy to Dev + working-directory: ./deploy/aliyun/environments/dev + run: | + kubectl apply -f 01-namespace.yaml + kubectl apply -f 02-configmap.yaml + kubectl apply -f 03-secret.yaml + kubectl apply -f 04-deployment.yaml + kubectl apply -f 05-service.yaml + kubectl apply -f 06-hpa.yaml + kubectl rollout status deployment/new-api -n new-api-dev --timeout=300s + + - name: Verify deployment + run: | + kubectl get pods -n new-api-dev + kubectl get svc -n new-api-dev + + # ========== 部署到预发环境 ========== + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: build + if: github.ref == 'refs/heads/main' + environment: + name: staging + url: http://staging.new-api.example.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.28.0' + + - name: Configure ACK credentials + uses: aliyun/ack-set-context@v1 + with: + access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }} + cluster-id: ${{ secrets.ACK_CLUSTER_ID_STAGING }} + + - name: Update image tag + working-directory: ./deploy/aliyun/environments/staging + run: | + IMAGE_TAG=$(echo "${{ needs.build.outputs.image_tag }}" | cut -d',' -f1) + sed -i "s|image: calciumion/new-api:.*|image: ${IMAGE_TAG}|" 04-deployment.yaml + + - name: Deploy to Staging + working-directory: ./deploy/aliyun/environments/staging + run: | + kubectl apply -k . + kubectl rollout status deployment/new-api -n new-api-staging --timeout=300s + + - name: Run smoke tests + run: | + STAGING_URL=$(kubectl get svc new-api-service -n new-api-staging -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + curl -f http://${STAGING_URL}/api/status || exit 1 + + # ========== 部署到生产环境 ========== + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: [build, deploy-staging] + if: startsWith(github.ref, 'refs/tags/v') + environment: + name: production + url: http://new-api.example.com + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup kubectl + uses: azure/setup-kubectl@v3 + with: + version: 'v1.28.0' + + - name: Configure ACK credentials + uses: aliyun/ack-set-context@v1 + with: + access-key-id: ${{ secrets.ALIYUN_ACCESS_KEY_ID }} + access-key-secret: ${{ secrets.ALIYUN_ACCESS_KEY_SECRET }} + cluster-id: ${{ secrets.ACK_CLUSTER_ID_PROD }} + + - name: Update image tag + working-directory: ./deploy/aliyun/environments/production + run: | + IMAGE_TAG=$(echo "${{ needs.build.outputs.image_tag }}" | cut -d',' -f1) + sed -i "s|image: calciumion/new-api:.*|image: ${IMAGE_TAG}|" 04-deployment.yaml + + - name: Deploy to Production + working-directory: ./deploy/aliyun/environments/production + run: | + kubectl apply -f 01-namespace.yaml + kubectl apply -f 02-configmap.yaml + kubectl apply -f 03-secret.yaml + kubectl apply -f 04-deployment.yaml + kubectl apply -f 05-service.yaml + kubectl apply -f 06-hpa.yaml + kubectl apply -f 07-pdb.yaml + kubectl rollout status deployment/new-api -n new-api-prod --timeout=600s + + - name: Verify deployment + run: | + kubectl get pods -n new-api-prod + PROD_URL=$(kubectl get svc new-api-service -n new-api-prod -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + curl -f http://${PROD_URL}/api/status || exit 1 + + - name: Notify Slack + if: always() + uses: 8398a7/action-slack@v3 + with: + status: ${{ job.status }} + channel: '#deployments' + text: 'Production deployment ${{ job.status }}' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + + # ========== 安全扫描 ========== + security-scan: + name: Security Scan + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ needs.build.outputs.image_tag }} + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy scan results + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000000..eb74ebc4a3ab --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,175 @@ +# GitLab CI/CD Pipeline for New-API + +variables: + DOCKER_REGISTRY: $CI_REGISTRY + IMAGE_NAME: $CI_REGISTRY_IMAGE + DOCKER_DRIVER: overlay2 + DOCKER_TLS_CERTDIR: "" + +stages: + - lint + - test + - build + - security + - deploy + +# ========== 代码检查 ========== +lint:go: + stage: lint + image: golang:1.22-alpine + before_script: + - apk add --no-cache git + - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + script: + - golangci-lint run --timeout=5m + only: + - merge_requests + - main + - develop + +lint:frontend: + stage: lint + image: oven/bun:latest + script: + - cd web && bun install + - bun run lint || true + only: + - merge_requests + - main + - develop + +# ========== 单元测试 ========== +test:go: + stage: test + image: golang:1.22-alpine + services: + - mysql:8.0 + - redis:7-alpine + variables: + MYSQL_ROOT_PASSWORD: test + MYSQL_DATABASE: new_api_test + script: + - go test -v -race -coverprofile=coverage.out ./... + - go tool cover -func=coverage.out + coverage: '/total:\s*\d+\.\d+%/' + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: coverage.out + paths: + - coverage.out + only: + - merge_requests + - main + - develop + +# ========== 构建镜像 ========== +build:image: + stage: build + image: docker:24-dind + services: + - docker:24-dind + before_script: + - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY + script: + - | + if [ "$CI_COMMIT_BRANCH" == "main" ]; then + TAG="stable" + elif [ "$CI_COMMIT_BRANCH" == "develop" ]; then + TAG="latest" + else + TAG=$CI_COMMIT_SHORT_SHA + fi + - docker build -t $IMAGE_NAME:$TAG . + - docker push $IMAGE_NAME:$TAG + - docker tag $IMAGE_NAME:$TAG $IMAGE_NAME:$CI_COMMIT_SHORT_SHA + - docker push $IMAGE_NAME:$CI_COMMIT_SHORT_SHA + only: + - main + - develop + +# ========== 安全扫描 ========== +security:trivy: + stage: security + image: aquasec/trivy:latest + script: + - trivy image --exit-code 0 --no-progress $IMAGE_NAME:$CI_COMMIT_SHORT_SHA + allow_failure: true + only: + - main + - develop + +# ========== 部署到开发环境 ========== +deploy:dev: + stage: deploy + image: bitnami/kubectl:1.28 + environment: + name: development + url: http://dev.new-api.example.com + before_script: + - kubectl config use-context $KUBE_CONTEXT_DEV + script: + - cd deploy/aliyun/environments/dev + - sed -i "s|image: calciumion/new-api:.*|image: $IMAGE_NAME:$CI_COMMIT_SHORT_SHA|" 04-deployment.yaml + - kubectl apply -f 01-namespace.yaml + - kubectl apply -f 02-configmap.yaml + - kubectl apply -f 03-secret.yaml + - kubectl apply -f 04-deployment.yaml + - kubectl apply -f 05-service.yaml + - kubectl apply -f 06-hpa.yaml + - kubectl rollout status deployment/new-api -n new-api-dev --timeout=300s + only: + - develop + +# ========== 部署到预发环境 ========== +deploy:staging: + stage: deploy + image: bitnami/kubectl:1.28 + environment: + name: staging + url: http://staging.new-api.example.com + before_script: + - kubectl config use-context $KUBE_CONTEXT_STAGING + script: + - cd deploy/aliyun/environments/staging + - sed -i "s|image: calciumion/new-api:.*|image: $IMAGE_NAME:$CI_COMMIT_SHORT_SHA|" 04-deployment.yaml + - kubectl apply -f 01-namespace.yaml + - kubectl apply -f 02-configmap.yaml + - kubectl apply -f 03-secret.yaml + - kubectl apply -f 04-deployment.yaml + - kubectl apply -f 05-service.yaml + - kubectl apply -f 06-hpa.yaml + - kubectl rollout status deployment/new-api -n new-api-staging --timeout=300s + - | + STAGING_URL=$(kubectl get svc new-api-service -n new-api-staging -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + curl -f http://${STAGING_URL}/api/status || exit 1 + only: + - main + +# ========== 部署到生产环境 ========== +deploy:production: + stage: deploy + image: bitnami/kubectl:1.28 + environment: + name: production + url: http://new-api.example.com + before_script: + - kubectl config use-context $KUBE_CONTEXT_PROD + script: + - cd deploy/aliyun/environments/production + - | + # 使用 Git Tag 作为版本号 + VERSION=${CI_COMMIT_TAG#v} + sed -i "s|image: calciumion/new-api:.*|image: $IMAGE_NAME:$VERSION|" 04-deployment.yaml + - kubectl apply -f 01-namespace.yaml + - kubectl apply -f 02-configmap.yaml + - kubectl apply -f 03-secret.yaml + - kubectl apply -f 04-deployment.yaml + - kubectl apply -f 05-service.yaml + - kubectl apply -f 06-hpa.yaml + - kubectl apply -f 07-pdb.yaml + - kubectl rollout status deployment/new-api -n new-api-prod --timeout=600s + when: manual + only: + - tags diff --git a/DEVELOPMENT_OPS_GUIDE.md b/DEVELOPMENT_OPS_GUIDE.md new file mode 100644 index 000000000000..7acdfdfa18bf --- /dev/null +++ b/DEVELOPMENT_OPS_GUIDE.md @@ -0,0 +1,215 @@ +# `new-api` 项目开发运维最佳实践文档 + +## 1. 概述 + +本文档旨在为 `new-api` 项目的开发、部署和运维提供一套标准化的最佳实践指南。核心目标是利用阿里云产品构建一个高可用、弹性伸缩、安全可靠的系统,并规范开发流程以支持高效的 CI/CD。 + +## 2. 核心服务概览 + +`new-api` 是一个基于 Go 语言的 AI API 网关,其核心架构特点如下: + +* **后端**: Go 1.22+, Gin Web 框架。 +* **前端**: React 18, Vite, Semi Design UI (前端静态文件内置于 Go 二进制文件)。 +* **数据库**: 支持 SQLite, MySQL, PostgreSQL。 +* **缓存**: Redis。 +* **部署方式**: Docker 容器化部署。 + +## 3. 阿里云产品选型 + +为满足高可用、弹性、持久化和 CI/CD 的需求,我们将选用以下阿里云产品: + +| **产品名称** | **功能描述** | **高可用/弹性特性** | +| :------------------- | :------------------------------------------- | :----------------------------------------------------------------------------------------- | +| **容器服务 ACK** | Kubernetes 容器管理平台 | 多可用区集群部署、节点自动伸缩、Pod 自动伸缩 (HPA)、滚动更新、故障自愈 | +| **弹性容器实例 ECI** | 无服务器容器服务 (可选) | 秒级启动、按需付费、无需管理服务器 | +| **负载均衡 SLB** | 流量分发与负载均衡 | 跨可用区部署、健康检查、会话保持、多种负载均衡算法 | +| **对象存储 OSS** | 高可靠、低成本的云存储 | 多副本存储、数据强一致性、生命周期管理 | +| **内容分发网络 CDN** | 全球内容分发加速 | 边缘缓存、低延迟、高并发、安全防护 | +| **云数据库 RDS** | 托管式关系型数据库 (MySQL/PostgreSQL) | 主备架构、自动故障转移、数据备份与恢复、读写分离、多可用区部署 | +| **云数据库 Redis** | 托管式内存数据库 | 主从架构、自动故障转移、数据持久化 | +| **容器镜像服务 ACR** | 托管式 Docker 镜像仓库 | 高性能、高安全性、全球分发、支持镜像扫描 | +| **云效 DevOps** | 一站式研发管理平台 (CI/CD) | 自动化构建、测试、部署、集成代码扫描、审批流 | +| **日志服务 SLS** | 日志采集、存储、查询、分析 | 高并发日志写入、实时查询、多维度分析、告警通知 | +| **云监控 CloudMonitor** | 资源性能监控与告警 | 全方位监控云资源、自定义监控、告警管理 | +| **应用实时监控 ARMS** | 应用性能管理 (APM) | 链路追踪、慢事务分析、前端性能监控 | +| **专有网络 VPC** | 隔离网络环境 | 逻辑隔离、自定义网络拓扑、安全组、路由表 | +| **访问控制 RAM** | 统一身份认证与权限管理 | 最小权限原则、多因素认证 (MFA)、身份联邦 | +| **密钥管理服务 KMS** | 密钥生命周期管理与加密服务 | 密钥安全存储、统一管理、审计 | + +## 4. 环境规划 + +我们将环境划分为**测试环境 (Staging/Test)** 和**生产环境 (Production)**,并采用 GitOps 理念管理 Kubernetes 配置。 + +### 4.1 测试环境 (Staging/Test) + +* **目的**: 验证新功能、进行集成测试、性能测试、用户验收测试 (UAT)。 +* **资源规模**: 资源配置相对较低,满足测试需求即可。 +* **数据**: 可以使用生产数据的脱敏子集或模拟数据。 +* **部署**: 自动化部署,每次代码合并到 `develop` 或 `release` 分支后自动触发。 +* **特点**: 快速迭代,允许一定程度的实验。 + +### 4.2 生产环境 (Production) + +* **目的**: 对外提供稳定、高性能的服务。 +* **资源规模**: 高可用配置,充足的资源以应对业务负载。 +* **数据**: 真实生产数据。 +* **部署**: 严格的审批流程,手动或半自动化触发,通常从 `main` 分支部署。 +* **特点**: 强调稳定性、安全性、性能和灾备。 + +## 5. 开发流程与代码管理 + +我们采用 Git Flow 分支模型。 + +* **`main` 分支**: 保持稳定,只包含已发布到生产环境的代码。 +* **`develop` 分支**: 集成所有新功能开发,部署到测试环境。 +* **`feature/*` 分支**: 基于 `develop` 创建,用于开发独立功能。 +* **`release/*` 分支**: 基于 `develop` 创建,用于发布前的最后测试和 Bug 修复。 +* **`hotfix/*` 分支**: 基于 `main` 创建,用于紧急 Bug 修复并直接合并到 `main` 和 `develop`。 + +**开发流程**: + +1. 从 `develop` 分支创建 `feature/*` 分支进行功能开发。 +2. 完成开发后,提交代码,并创建 Pull Request (PR) 到 `develop` 分支。 +3. PR 需经过代码审查 (Code Review) 和自动化测试。 +4. 合并到 `develop` 分支后,自动触发测试环境的 CI/CD 流程。 +5. 测试通过后,创建 `release/*` 分支进行发布准备。 +6. `release/*` 分支经过充分测试后,合并到 `main` 和 `develop`,并在 `main` 分支上打 Tag。 +7. Tag 推送后,自动触发生产环境的 CI/CD 流程。 + +## 6. CI/CD 流程 (基于阿里云云效 DevOps) + +利用阿里云云效 DevOps,我们可以构建端到端的 CI/CD 流水线。 + +### 6.1 代码提交与测试 + +1. **代码仓库**: `new-api` 代码托管在 Git 仓库 (例如阿里云 CodeUp)。 +2. **触发器**: 代码提交到 `develop` 或 `main` 分支,或创建 Tag 时触发流水线。 +3. **静态代码分析**: 使用 SonarQube 或云效内置的代码扫描工具进行代码质量检查。 +4. **单元测试**: 运行 Go 后端的单元测试 (`go test ./...`) 和前端的单元测试 (例如 `bun test`)。 + +### 6.2 镜像构建与推送 + +1. **编译前端**: 在 CI 步骤中,执行 `cd web && bun install && bun run build`,生成前端静态文件。 +2. **构建 Docker 镜像**: 使用项目中的 [Dockerfile](file:///Users/wangruntao/CodeBuddy/new-api/Dockerfile) 构建 Docker 镜像。根据环境和分支,生成带有不同 Tag 的镜像。 + * **测试环境**: `your-acr-registry/new-api:test-` + * **生产环境**: `your-acr-registry/new-api:vX.Y.Z` (基于 Git Tag) +3. **推送到 ACR**: 将构建好的 Docker 镜像推送到阿里云容器镜像服务 ACR。 +4. **镜像安全扫描**: ACR 自动对镜像进行安全漏洞扫描。 + +### 6.3 部署到测试环境 + +1. **触发条件**: 代码合并到 `develop` 分支后自动触发。 +2. **Kubernetes YAML 更新**: CI/CD 流水线获取测试环境的 Kubernetes 部署 YAML 文件。 +3. **变量替换**: 替换 YAML 文件中的镜像 Tag、数据库连接字符串、Redis 连接字符串等环境变量,指向测试环境的阿里云 RDS 和 Redis 实例,以及最新构建的 `test` 镜像。 +4. **Kubectl 部署**: 使用 `kubectl apply -f ` 命令将应用部署到测试 ACK 集群。 +5. **自动化测试**: 部署成功后,运行自动化集成测试和接口测试,确保服务正常运行。 + +### 6.4 部署到生产环境 + +1. **触发条件**: `main` 分支打 Tag (`vX.Y.Z`) 后手动或半自动化触发。 +2. **审批流程**: 部署到生产环境前,需经过严格的审批流程。 +3. **Kubernetes YAML 更新**: 获取生产环境的 Kubernetes 部署 YAML 文件。 +4. **变量替换**: 替换 YAML 文件中的镜像 Tag、数据库连接字符串、Redis 连接字符串等环境变量,指向生产环境的阿里云 RDS 和 Redis 实例,以及最新 Tag 的生产镜像。所有敏感信息通过 Kubernetes Secret 管理,并从阿里云 KMS 或云效的敏感配置中获取。 +5. **滚动更新**: ACK Deployment 默认采用滚动更新策略,确保服务不中断。 +6. **部署验证**: 部署完成后,进行健康检查、核心业务流程验证。 + +## 7. ACK 部署最佳实践 + +### 7.1 Kubernetes 资源管理 + +我们将使用以下 Kubernetes 资源来部署 `new-api` 应用。 + +* **Namespace**: 为 `new-api` 创建独立的命名空间 (例如 `new-api-test`, `new-api-prod`) 进行资源隔离。 +* **Deployment**: + * 管理 `new-api` Pod 的副本数量,确保至少 2 个副本以实现高可用。 + * 配置 `livenessProbe` 和 `readinessProbe`,确保 Pod 的健康状态。 + * 配置资源请求 (requests) 和限制 (limits),防止资源争抢。 + * 采用滚动更新策略,最小化服务中断。 +* **Service**: + * 使用 `ClusterIP` 类型的 Service 作为 `new-api` Pod 的内部访问入口。 + * Service 端口 (`80`) 映射到 Pod 端口 (`3000`)。 +* **Ingress**: + * 通过阿里云 ACK 提供的 Ingress Controller (如 Nginx Ingress 或 SLB Ingress) 对外暴露服务。 + * 配置域名、HTTPS 证书 (使用 Cert-manager 或 SLB 证书管理)。 + * 配置 Ingress 规则,将外部请求路由到 `new-api` Service。 +* **Horizontal Pod Autoscaler (HPA)**: + * 根据 CPU 利用率和/或内存利用率配置 HPA,实现 Pod 的自动伸缩。 + * 设置 `minReplicas` (例如 2) 和 `maxReplicas`。 +* **Secret**: + * 用于存储敏感信息,如 `SESSION_SECRET`, `CRYPTO_SECRET`, `SQL_DSN`, `REDIS_CONN_STRING` 等。 + * 通过 `envFrom` 引用到 Deployment 中,避免敏感信息硬编码。 + * 结合阿里云 KMS 管理 Secret,增加安全性。 +* **ConfigMap**: + * 用于存储非敏感配置,如 `STREAMING_TIMEOUT`, `MAX_REQUEST_BODY_MB` 等。 + * 通过 `envFrom` 引用到 Deployment 中。 + +### 7.2 日志与监控 + +* **日志采集**: + * 配置 ACK 集群集成阿里云日志服务 (SLS),自动采集 `new-api` 容器的标准输出日志。 + * 将日志发送到不同的 Logstore (例如 `new-api-access-log`, `new-api-error-log`) 进行分类管理。 +* **监控**: + * 利用阿里云云监控 (CloudMonitor) 监控 ACK 集群、节点、Pod 的基础设施指标。 + * 利用阿里云应用实时监控 (ARMS) 监控 `new-api` 应用的性能指标,如请求量、延迟、错误率、响应时间、用户登录数等。 + * Go 应用可以集成 Prometheus 客户端暴露 metrics,通过 ACK 提供的 Prometheus 兼容服务进行采集。 +* **告警**: 基于 SLS 和 CloudMonitor/ARMS 的监控数据,配置告警规则,通过短信、邮件、钉钉等方式通知运维人员。 + +### 7.3 高可用与灾备 + +* **ACK 集群多可用区部署**: 确保 ACK 控制面和工作节点分布在不同的可用区,防止单可用区故障。 +* **Deployment 多副本**: 至少部署 2 个 `new-api` Pod 副本,并通过 `podAntiAffinity` 规则将它们分散到不同的节点上。 +* **RDS/Redis 多可用区部署**: 数据库和缓存实例配置为主备模式,并部署在不同的可用区。 +* **Ingress/SLB 跨可用区**: 负载均衡器配置为跨可用区转发流量。 +* **数据备份**: 定期对 RDS 和 Redis 数据进行备份,并测试恢复流程。 + +## 8. 数据库与缓存最佳实践 + +### 8.1 云数据库 RDS (MySQL/PostgreSQL) + +* **高可用**: 选择**多可用区部署**,实例默认提供主备架构,支持自动故障转移。 +* **性能**: 根据业务负载选择合适的实例规格 (CPU/内存/存储 IOPS)。 +* **备份**: 开启自动备份功能,设置合理的备份周期和保留策略。 +* **网络**: RDS 实例部署在 VPC 内部,并通过安全组限制访问来源,只允许 ACK 集群的 Pod IP 或 CIDR 访问。 +* **连接字符串**: 敏感连接信息 (`SQL_DSN`) 存储在 Kubernetes Secret 中。 +* **版本管理**: 数据库变更 (Migration) 应作为 CI/CD 流水线的一部分,并在测试环境充分验证后才应用于生产环境。 + +### 8.2 云数据库 Redis + +* **高可用**: 选择**主从版**或**集群版**,支持自动故障转移。 +* **性能**: 根据业务需求选择合适的实例规格,特别是内存大小。 +* **持久化**: 根据数据重要性选择 RDB 或 AOF 持久化策略,或两者结合。 +* **网络**: Redis 实例部署在 VPC 内部,并通过安全组限制访问来源。 +* **连接字符串**: 敏感连接信息 (`REDIS_CONN_STRING`) 存储在 Kubernetes Secret 中。 +* **`CRYPTO_SECRET`**: 确保所有 `new-api` 实例的 `CRYPTO_SECRET` 一致,以便正确解密 Redis 中存储的数据。 + +## 9. 安全最佳实践 + +* **VPC 网络隔离**: 将所有云资源部署在私有 VPC 中,并通过 VSwitch 进行子网划分。 +* **安全组**: 严格限制每个云资源的入站和出站规则,采用最小权限原则。例如,数据库只允许来自 ACK 集群的访问。 +* **IAM 权限管理**: + * 为每个团队成员分配最小必要权限的 RAM 用户。 + * 为 ACK 集群的 Pod 配置 RAM Role (通过 OIDC 或 STS 方式),使 Pod 能够以角色身份访问其他阿里云服务,避免硬编码 AK/SK。 +* **密钥管理 KMS**: 将所有敏感密钥 (如 `SESSION_SECRET`, `CRYPTO_SECRET`, API Key 等) 存储在阿里云 KMS 中,并通过 Kubernetes Secret 引用或在 CI/CD 过程中注入。 +* **容器镜像安全**: 定期使用 ACR 镜像安全扫描功能,及时发现并修复镜像中的漏洞。 +* **网络安全**: 结合阿里云 Web 应用防火墙 (WAF) 和 DDoS 高防,保护 SLB/Ingress 暴露的外部访问。 + +## 10. 运维与监控 + +* **统一监控平台**: 使用阿里云 CloudMonitor 和 ARMS 集中监控所有云资源和应用的性能指标。 +* **自定义监控**: 针对 `new-api` 的业务指标 (例如 API 请求量、错误率、响应时间、用户登录数等) 进行自定义监控。 +* **告警通知**: 配置多级告警规则,结合钉钉、短信、邮件等通知渠道,确保故障及时响应。 +* **日志分析**: 利用 SLS 的日志查询和分析能力,快速定位和解决问题。 +* **成本管理**: 定期审查云资源使用情况和费用,进行成本优化。 + +## 11. 回滚策略 + +在部署新版本出现问题时,需要有快速有效的回滚策略。 + +* **Kubernetes Deployment 回滚**: + * 由于 Deployment 采用滚动更新,可以通过 `kubectl rollout undo deployment/` 命令快速回滚到上一个稳定版本。 + * 在部署新版本前,务必确保历史版本镜像可用。 +* **数据库回滚**: + * 对于数据库结构变更,应设计向下兼容的变更脚本。如果无法兼容,需在回滚前有明确的数据库恢复计划 (例如从备份恢复)。 + * **重要**: 避免在生产环境进行不可逆的数据库变更,或确保有充分的验证和备份。 +* **镜像版本管理**: 在 ACR 中保留多个稳定版本的 Docker 镜像,以便回滚。 +* **CI/CD 回滚**: 云效流水线应支持一键回滚到指定历史版本的部署。 diff --git a/controller/skill.go b/controller/skill.go new file mode 100644 index 000000000000..ac6c12f6532b --- /dev/null +++ b/controller/skill.go @@ -0,0 +1,259 @@ +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/gin-gonic/gin" +) + +// GetAllSkills 获取技能列表(支持分页和 tag 过滤) +func GetAllSkills(c *gin.Context) { + pageInfo := common.GetPageQuery(c) + tag := c.Query("tag") + + skills, err := model.GetAllSkills(pageInfo.GetStartIdx(), pageInfo.GetPageSize(), tag) + if err != nil { + common.ApiError(c, err) + return + } + + total, err := model.CountSkills(tag) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(skills) + common.ApiSuccess(c, pageInfo) +} + +// GetSkill 获取单个技能详情 +func GetSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid skill ID") + return + } + + skill, err := model.GetSkillById(id) + if err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, skill) +} + +// CreateSkillRequest 创建技能请求结构 +type CreateSkillRequest struct { + Slug string `json:"slug" binding:"required"` + Title string `json:"title" binding:"required"` + Description string `json:"description"` + AvatarUrl *string `json:"avatar_url"` + CategoryId int `json:"category_id"` + CategoryAvatarUrl string `json:"category_avatar_url"` + Version string `json:"version"` + ActualUrl string `json:"actual_url"` + Tag string `json:"tag"` + Downloads int `json:"downloads"` + Stars int `json:"stars"` + CoreFeatures []model.SkillCoreFeature `json:"core_features"` + UseCases []string `json:"use_cases"` + IsActive bool `json:"is_active"` +} + +// AddSkill 创建技能 +func AddSkill(c *gin.Context) { + var req CreateSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + skill := &model.Skill{ + Slug: req.Slug, + Title: req.Title, + Description: req.Description, + AvatarUrl: req.AvatarUrl, + CategoryId: req.CategoryId, + CategoryAvatarUrl: req.CategoryAvatarUrl, + Version: req.Version, + ActualUrl: req.ActualUrl, + Tag: req.Tag, + Downloads: req.Downloads, + Stars: req.Stars, + CoreFeatures: req.CoreFeatures, + UseCases: req.UseCases, + IsActive: req.IsActive, + } + + if err := model.CreateSkill(skill); err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, skill) +} + +// UpdateSkillRequest 更新技能请求结构 +type UpdateSkillRequest struct { + Slug string `json:"slug"` + Title string `json:"title"` + Description string `json:"description"` + AvatarUrl *string `json:"avatar_url"` + CategoryId int `json:"category_id"` + CategoryAvatarUrl string `json:"category_avatar_url"` + Version string `json:"version"` + ActualUrl string `json:"actual_url"` + Tag string `json:"tag"` + Downloads int `json:"downloads"` + Stars int `json:"stars"` + CoreFeatures []model.SkillCoreFeature `json:"core_features"` + UseCases []string `json:"use_cases"` + IsActive bool `json:"is_active"` +} + +// UpdateSkill 更新技能 +func UpdateSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid skill ID") + return + } + + skill, err := model.GetSkillById(id) + if err != nil { + common.ApiError(c, err) + return + } + + var req UpdateSkillRequest + if err := c.ShouldBindJSON(&req); err != nil { + common.ApiError(c, err) + return + } + + // 更新字段(只更新非零值) + if req.Slug != "" { + skill.Slug = req.Slug + } + if req.Title != "" { + skill.Title = req.Title + } + if req.Description != "" { + skill.Description = req.Description + } + if req.AvatarUrl != nil { + skill.AvatarUrl = req.AvatarUrl + } + if req.CategoryId != 0 { + skill.CategoryId = req.CategoryId + } + skill.CategoryAvatarUrl = req.CategoryAvatarUrl + if req.Version != "" { + skill.Version = req.Version + } + if req.ActualUrl != "" { + skill.ActualUrl = req.ActualUrl + } + if req.Tag != "" { + skill.Tag = req.Tag + } + skill.Downloads = req.Downloads + skill.Stars = req.Stars + if req.CoreFeatures != nil { + skill.CoreFeatures = req.CoreFeatures + } + if req.UseCases != nil { + skill.UseCases = req.UseCases + } + skill.IsActive = req.IsActive + + if err := model.UpdateSkill(skill); err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, skill) +} + +// DeleteSkill 删除技能 +func DeleteSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid skill ID") + return + } + + if err := model.DeleteSkill(id); err != nil { + common.ApiError(c, err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Skill deleted successfully", + }) +} + +// SearchSkills 搜索技能 +func SearchSkills(c *gin.Context) { + keyword := c.Query("keyword") + pageInfo := common.GetPageQuery(c) + + skills, total, err := model.SearchSkills(keyword, pageInfo.GetStartIdx(), pageInfo.GetPageSize()) + if err != nil { + common.ApiError(c, err) + return + } + + pageInfo.SetTotal(int(total)) + pageInfo.SetItems(skills) + common.ApiSuccess(c, pageInfo) +} + +// GetAllSkillTags 获取所有标签 +func GetAllSkillTags(c *gin.Context) { + tags, err := model.GetAllSkillTags() + if err != nil { + common.ApiError(c, err) + return + } + + common.ApiSuccess(c, gin.H{ + "tags": tags, + }) +} + +// DownloadSkill 下载技能文件并增加下载计数 +func DownloadSkill(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + common.ApiErrorMsg(c, "Invalid skill ID") + return + } + + skill, err := model.GetSkillById(id) + if err != nil { + common.ApiError(c, err) + return + } + + // 增加下载计数 + _ = model.IncrementDownloads(id) + + // 返回下载 URL + c.JSON(http.StatusOK, gin.H{ + "success": true, + "download_url": skill.ActualUrl, + "skill": skill, + }) +} \ No newline at end of file diff --git a/controller/topup.go b/controller/topup.go index e7a392a4d31d..8a18eec077fa 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -1,6 +1,7 @@ package controller import ( + "errors" "fmt" "log" "net/url" @@ -82,20 +83,20 @@ func GetTopUpInfo(c *gin.Context) { "enable_online_topup": operation_setting.PayAddress != "" && operation_setting.EpayId != "" && operation_setting.EpayKey != "", "enable_stripe_topup": setting.StripeApiSecret != "" && setting.StripeWebhookSecret != "" && setting.StripePriceId != "", "enable_creem_topup": setting.CreemApiKey != "" && setting.CreemProducts != "[]", - "enable_waffo_topup": enableWaffo, + "enable_waffo_topup": enableWaffo, "waffo_pay_methods": func() interface{} { if enableWaffo { return setting.GetWaffoPayMethods() } return nil }(), - "creem_products": setting.CreemProducts, - "pay_methods": payMethods, - "min_topup": operation_setting.MinTopUp, - "stripe_min_topup": setting.StripeMinTopUp, - "waffo_min_topup": setting.WaffoMinTopUp, - "amount_options": operation_setting.GetPaymentSetting().AmountOptions, - "discount": operation_setting.GetPaymentSetting().AmountDiscount, + "creem_products": setting.CreemProducts, + "pay_methods": payMethods, + "min_topup": operation_setting.MinTopUp, + "stripe_min_topup": setting.StripeMinTopUp, + "waffo_min_topup": setting.WaffoMinTopUp, + "amount_options": operation_setting.GetPaymentSetting().AmountOptions, + "discount": operation_setting.GetPaymentSetting().AmountDiscount, } common.ApiSuccess(c, data) } @@ -188,10 +189,18 @@ func RequestEpay(c *gin.Context) { } if !operation_setting.ContainsPayMethod(req.PaymentMethod) { - c.JSON(200, gin.H{"message": "error", "data": "支付方式不存在"}) + common.ApiError(c, errors.New("支付方式不存在")) return } + epayType := req.PaymentMethod + switch req.PaymentMethod { + case "wechat_qr": + epayType = "wxpay" + case "alipay_qr": + epayType = "alipay" + } + callBackAddress := service.GetCallbackAddress() returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/log") notifyUrl, _ := url.Parse(callBackAddress + "/api/user/epay/notify") @@ -199,11 +208,11 @@ func RequestEpay(c *gin.Context) { tradeNo = fmt.Sprintf("USR%dNO%s", id, tradeNo) client := GetEpayClient() if client == nil { - c.JSON(200, gin.H{"message": "error", "data": "当前管理员未配置支付信息"}) + common.ApiError(c, errors.New("当前管理员未配置支付信息")) return } uri, params, err := client.Purchase(&epay.PurchaseArgs{ - Type: req.PaymentMethod, + Type: epayType, ServiceTradeNo: tradeNo, Name: fmt.Sprintf("TUC%d", req.Amount), Money: strconv.FormatFloat(payMoney, 'f', 2, 64), @@ -212,7 +221,7 @@ func RequestEpay(c *gin.Context) { ReturnUrl: returnUrl, }) if err != nil { - c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + common.ApiError(c, err) return } amount := req.Amount @@ -232,10 +241,14 @@ func RequestEpay(c *gin.Context) { } err = topUp.Insert() if err != nil { - c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + common.ApiError(c, err) return } - c.JSON(200, gin.H{"message": "success", "data": params, "url": uri}) + c.JSON(200, gin.H{ + "message": "success", + "url": uri, + "data": params, + }) } // tradeNo lock @@ -463,4 +476,3 @@ func AdminCompleteTopUp(c *gin.Context) { } common.ApiSuccess(c, nil) } - diff --git a/controller/topup_stripe.go b/controller/topup_stripe.go index e1718cc5ec87..38090dd05247 100644 --- a/controller/topup_stripe.go +++ b/controller/topup_stripe.go @@ -146,6 +146,12 @@ func RequestStripePay(c *gin.Context) { } func StripeWebhook(c *gin.Context) { + // 检查 webhook secret 是否配置 + if setting.StripeWebhookSecret == "" { + log.Println("⚠️ Stripe Webhook secret Error") + c.AbortWithStatus(http.StatusServiceUnavailable) + return + } payload, err := io.ReadAll(c.Request.Body) if err != nil { log.Printf("解析Stripe Webhook参数失败: %v\n", err) diff --git a/deploy/docs/ACK_SETUP_GUIDE.md b/deploy/docs/ACK_SETUP_GUIDE.md new file mode 100644 index 000000000000..13a7ff06a4bc --- /dev/null +++ b/deploy/docs/ACK_SETUP_GUIDE.md @@ -0,0 +1,504 @@ +# 阿里云 ACK 环境初始化及 `new-api` 部署指南 + +## 1. 概述 + +本文档旨在指导您在阿里云上初始化 `new-api` 项目所需的各项基础设施,并分环境部署 `new-api` 应用。我们将利用阿里云的容器服务 ACK、云数据库 RDS、云数据库 Redis 等产品,并结合 CI/CD 流程,构建一个高可用、弹性伸缩、安全可靠的运行环境。 + +## 2. 阿里云资源准备 + +在部署 `new-api` 之前,请确保您已拥有阿里云账号,并完成实名认证。 + +### 2.1 专有网络 VPC 及交换机 VSwitch + +**目的**:为您的云资源提供一个隔离、安全的网络环境,并实现跨可用区容灾。 + +1. **登录阿里云控制台**:进入 [VPC 控制台](https://vpc.console.aliyun.com/vpc)。 +2. **创建 VPC**: + * **地域**:选择您的业务所在地域 (例如:华东1-杭州)。 + * **VPC 名称**:`new-api-vpc` (建议) + * **IPv4 CIDR Block**:`10.0.0.0/8` (或根据您的网络规划自定义) +3. **创建 VSwitch**:在 VPC 详情页中,至少在**两个不同的可用区**创建 VSwitch。这将确保即使一个可用区发生故障,您的服务也能继续运行。 + * **可用区 A VSwitch**:`new-api-vsw-az-a`,CIDR Block:`10.0.1.0/24` + * **可用区 B VSwitch**:`new-api-vsw-az-b`,CIDR Block:`10.0.2.0/24` + +### 2.2 容器服务 ACK (Kubernetes) + +**目的**:托管 `new-api` 应用容器,提供高可用和弹性伸缩能力。 + +1. **登录阿里云控制台**:进入 [ACK 控制台](https://cs.console.aliyun.com/)。 +2. **创建 Kubernetes 集群**:推荐选择**标准版托管集群**。 + * **集群名称**:`new-api-ack-cluster` + * **地域**:与 VPC 相同 + * **VPC**:选择 `new-api-vpc` + * **交换机**:选择您创建的两个 VSwitch (`new-api-vsw-az-a`, `new-api-vsw-az-b`) + * **工作节点配置**: + * **实例类型**:根据预算和性能需求选择,建议至少 `ecs.g7.large` (2核8G) 或更高。 + * **数量**:测试环境至少 2 台,生产环境至少 3 台,分布在不同可用区。 + * **操作系统**:Alibaba Cloud Linux 或 CentOS。 + * **高级配置**: + * **开启网络策略 (Network Policy)**:增强网络安全。 + * **开启日志服务 (SLS)**:自动采集集群和容器日志 (选择您创建的 Log Service Project 和 Logstore)。 + * **开启 Prometheus 监控**:用于采集应用指标。 + * **Ingress 组件**:选择安装 `Nginx Ingress Controller` 或 `阿里云 SLB Ingress Controller`。 +3. **配置 Kubectl**:根据 ACK 控制台的指引,配置本地 `kubectl` 工具,确保可以连接到您的 ACK 集群。 + +### 2.3 云数据库 RDS (MySQL / PostgreSQL) + +**目的**:为 `new-api` 提供高可用、持久化的关系型数据库服务。 + +1. **登录阿里云控制台**:进入 [RDS 控制台](https://rds.console.aliyun.com/)。 +2. **创建 RDS 实例**: + * **计费方式**:按量付费 (测试环境) / 包年包月 (生产环境)。 + * **地域**:与 ACK 集群相同。 + * **数据库类型**:MySQL (推荐) 或 PostgreSQL。 + * **版本**:MySQL 8.0 或 PostgreSQL 14+。 + * **部署方式**:**三节点企业版 (推荐)** 或 **高可用版 (主备)**,以确保高可用和数据可靠性。 + * **存储类型**:ESSD 云盘。 + * **实例规格**:根据环境和预期负载选择 (例如测试环境 `1核2G`,生产环境 `4核16G` 或更高)。 + * **存储空间**:根据数据量选择。 + * **VPC 网络**:选择 `new-api-vpc`。 + * **交换机**:选择不同可用区的 VSwitch,例如 `new-api-vsw-az-a` 和 `new-api-vsw-az-b`。 + * **高可用配置**:默认开启。 + * **备份策略**:开启自动备份。 +3. **创建数据库和账号**: + * 在 RDS 实例详情页中,创建 `new-api` 所需的数据库 (例如 `newapi_db`)。 + * 创建数据库账号 (例如 `newapi_user`) 并设置密码。 +4. **配置白名单**:在 RDS 实例详情页中,配置 IP 白名单,允许您的 ACK 集群所在的 VPC CIDR (例如 `10.0.0.0/8`) 访问。**不要设置为 `0.0.0.0/0`**。 +5. **获取连接信息**:记录 RDS 实例的内网连接地址和端口。 + +### 2.4 云数据库 Redis + +**目的**:为 `new-api` 提供高可用、高性能的缓存服务。 + +1. **登录阿里云控制台**:进入 [Redis 控制台](https://redis.console.aliyun.com/)。 +2. **创建 Redis 实例**: + * **计费方式**:按量付费 (测试环境) / 包年包月 (生产环境)。 + * **地域**:与 ACK 集群相同。 + * **版本**:Redis 5.0 或 6.0。 + * **架构**:**主从版 (推荐)** 或 **集群版**。 + * **实例规格**:根据缓存数据量和并发量选择 (例如测试环境 `256MB`,生产环境 `4GB` 或更高)。 + * **VPC 网络**:选择 `new-api-vpc`。 + * **交换机**:选择不同可用区的 VSwitch。 + * **密码**:设置 Redis 访问密码。 +3. **配置白名单**:在 Redis 实例详情页中,配置 IP 白名单,允许您的 ACK 集群所在的 VPC CIDR 访问。**不要设置为 `0.0.0.0/0`**。 +4. **获取连接信息**:记录 Redis 实例的内网连接地址和端口。 + +### 2.5 容器镜像服务 ACR + +**目的**:存储 `new-api` 的 Docker 镜像。 + +1. **登录阿里云控制台**:进入 [ACR 控制台](https://cr.console.aliyun.com/)。 +2. **创建个人版实例**:如果尚未创建,请创建一个个人版实例。 +3. **创建命名空间**:创建命名空间 (例如 `new-api-repo`)。 +4. **创建镜像仓库**:在命名空间下创建镜像仓库 (例如 `new-api`)。 +5. **配置凭证**:记录您的 ACR 登录凭证,用于 CI/CD 流水线推送镜像。 + +### 2.6 日志服务 SLS (可选,但强烈推荐) + +**目的**:集中采集、存储、查询和分析 `new-api` 应用日志。 + +1. **登录阿里云控制台**:进入 [SLS 控制台](https://sls.console.aliyun.com/)。 +2. **创建 Project**:`new-api-log-project`。 +3. **创建 Logstore**:例如 `new-api-access-log`, `new-api-error-log`, `new-api-metrics` 等。 +4. **配置 ACK 关联**: 在 ACK 集群创建时通常会提示关联 SLS,确保已开启容器日志采集。 + +## 3. Kubernetes 资源配置 (YAML) + +本章节提供 `new-api` 在 ACK 上部署所需的 Kubernetes YAML 文件模板。这些文件位于 `deploy/kubernetes` 目录下,并使用 `kustomize` 进行环境差异化管理。 + +### 3.1 `base` 目录 (通用配置) + +`deploy/kubernetes/base` 目录存放所有环境通用的 Kubernetes 资源配置。 + +#### 3.1.1 `namespace.yaml` + +```yaml +# deploy/kubernetes/base/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: new-api # 命名空间名称,将被 overlay 覆盖为 new-api-test 或 new-api-prod +``` + +#### 3.1.2 `configmap.yaml` + +```yaml +# deploy/kubernetes/base/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: new-api-config + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +data: + STREAMING_TIMEOUT: "300" + STREAM_SCANNER_MAX_BUFFER_MB: "64" + MAX_REQUEST_BODY_MB: "32" + # 其他非敏感环境变量 + # ERROR_LOG_ENABLED: "false" +``` + +#### 3.1.3 `service.yaml` + +```yaml +# deploy/kubernetes/base/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: new-api-service + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +spec: + selector: + app: new-api + ports: + - protocol: TCP + port: 80 # Service 监听的端口 + targetPort: 3000 # Pod 实际监听的端口 + type: ClusterIP # ClusterIP 类型,仅在集群内部可访问,通过 Ingress 对外暴露 +``` + +#### 3.1.4 `ingress.yaml` + +**注意**:Ingress 配置需要根据您选择的 Ingress Controller (Nginx 或 SLB Ingress) 进行调整,并替换您的域名和 SSL 证书 ID。 + +```yaml +# deploy/kubernetes/base/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: new-api-ingress + namespace: new-api # 命名空间名称,将被 overlay 覆盖 + annotations: + # 如果使用阿里云 SLB Ingress Controller,可以添加以下注解来配置 SLB + # service.beta.kubernetes.io/alicloud-loadbalancer-protocol-port: "https:443,http:80" + # service.beta.kubernetes.io/alicloud-loadbalancer-cert-id: "" # 替换为您的SSL证书ID + # service.beta.kubernetes.io/alicloud-loadbalancer-force-override-listeners: "true" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-uri: "/api/status" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-connect-port: "3000" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-interval: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-timeout: "5" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-unhealthy-threshold: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-healthy-threshold: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-spec: "slb.s1.small" # SLB实例规格 + # nginx.ingress.kubernetes.io/rewrite-target: / # 如果需要路径重写 +spec: + ingressClassName: nginx # 或者 alibaba-cloud,根据您的 Ingress Controller 类型配置 + rules: + - host: # 替换为您的实际域名,例如 new-api.yourcompany.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: new-api-service + port: + number: 80 + tls: + - hosts: + - + secretName: new-api-tls-secret # 存储TLS证书的Secret,需要手动创建或通过 Cert-manager 生成 +``` + +#### 3.1.5 `hpa.yaml` + +```yaml +# deploy/kubernetes/base/hpa.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: new-api-hpa + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: new-api-deployment # Deployment 名称,将被 overlay 覆盖 + minReplicas: 2 # 最小副本数,确保高可用 + maxReplicas: 10 # 最大副本数 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 # 当 CPU 平均使用率达到 70% 时扩容 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 # 当内存平均使用率达到 80% 时扩容 +``` + +### 3.2 `overlays` 目录 (环境差异化配置) + +`deploy/kubernetes/overlays` 目录包含针对不同环境的特定配置,通过 `kustomize` 应用到 `base` 配置上。 + +#### 3.2.1 测试环境 (`test`) + +`deploy/kubernetes/overlays/test/kustomization.yaml` + +```yaml +# deploy/kubernetes/overlays/test/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: new-api-test # 定义测试环境的命名空间 + +resources: + - ../../base # 引入基础配置 + +patches: + - path: deployment.yaml # 覆盖 Deployment 配置 + - path: secret.yaml # 覆盖 Secret 配置 +``` + +`deploy/kubernetes/overlays/test/deployment.yaml` + +```yaml +# deploy/kubernetes/overlays/test/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-api-deployment # 对应 base 中的 Deployment 名称 + namespace: new-api-test # 覆盖 base 中的命名空间 +spec: + replicas: 1 # 测试环境通常一个副本即可 + template: + spec: + containers: + - name: new-api + image: /new-api:test- # 测试环境镜像,CI/CD 时替换 + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" +``` + +`deploy/kubernetes/overlays/test/secret.yaml` + +```yaml +# deploy/kubernetes/overlays/test/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: new-api-secrets + namespace: new-api-test # 覆盖 base 中的命名空间 +type: Opaque +data: + SESSION_SECRET: + CRYPTO_SECRET: + SQL_DSN: # 指向测试环境 RDS + REDIS_CONN_STRING: # 指向测试环境 Redis +``` + +#### 3.2.2 生产环境 (`prod`) + +`deploy/kubernetes/overlays/prod/kustomization.yaml` + +```yaml +# deploy/kubernetes/overlays/prod/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: new-api-prod # 定义生产环境的命名空间 + +resources: + - ../../base # 引入基础配置 + +patches: + - path: deployment.yaml # 覆盖 Deployment 配置 + - path: secret.yaml # 覆盖 Secret 配置 +``` + +`deploy/kubernetes/overlays/prod/deployment.yaml` + +```yaml +# deploy/kubernetes/overlays/prod/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-api-deployment + namespace: new-api-prod # 覆盖 base 中的命名空间 +spec: + replicas: 2 # 生产环境至少 2 个副本 + template: + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: new-api + topologyKey: kubernetes.io/hostname # 将 Pod 分散到不同节点 + containers: + - name: new-api + image: /new-api:vX.Y.Z # 生产环境镜像,CI/CD 时替换为版本号 Tag + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "1000m" + memory: "1024Mi" +``` + +`deploy/kubernetes/overlays/prod/secret.yaml` + +```yaml +# deploy/kubernetes/overlays/prod/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: new-api-secrets + namespace: new-api-prod # 覆盖 base 中的命名空间 +type: Opaque +data: + SESSION_SECRET: + CRYPTO_SECRET: + SQL_DSN: # 指向生产环境 RDS + REDIS_CONN_STRING: # 指向生产环境 Redis +``` + +## 4. 辅助脚本 + +这些脚本将帮助您自动化镜像构建、推送和应用部署的过程。 + +### 4.1 `scripts/build_and_push_image.sh` + +```bash +# deploy/scripts/build_and_push_image.sh +#!/bin/bash + +set -euo pipefail + +# 配置变量 +ACR_REGISTRY="" # 替换为您的 ACR 仓库地址,例如 registry.cn-hangzhou.aliyuncs.com/your-namespace +IMAGE_NAME="new-api" + +# 获取 Git Commit SHA 作为默认的测试环境 Tag +COMMIT_SHA=$(git rev-parse --short HEAD) + +# 接受参数:环境 (test/prod) 和版本号 (仅用于生产环境) +ENV="$1" +VERSION="$2" + +# 根据环境设置镜像 Tag +IMAGE_TAG="" +if [[ "$ENV" == "test" ]]; then + IMAGE_TAG="test-$COMMIT_SHA" +elif [[ "$ENV" == "prod" ]]; then + if [[ -z "$VERSION" ]]; then + echo "Error: For 'prod' environment, a version tag (e.g., v1.0.0) must be provided." + exit 1 + fi + IMAGE_TAG="$VERSION" +else + echo "Usage: $0 [version_tag_for_prod]" + echo "Example (test): $0 test" + echo "Example (prod): $0 prod v1.0.0" + exit 1 +fi + +FULL_IMAGE_NAME="${ACR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building Docker image: ${FULL_IMAGE_NAME}" +# 构建 Docker 镜像 (假设 Dockerfile 在项目根目录) +docker build -t "${FULL_IMAGE_NAME}" . + +echo "Logging in to ACR registry: ${ACR_REGISTRY}" +# 登录 ACR (确保您的 Docker 客户端已配置 ACR 凭证,或在此处使用 docker login) +# 例如:echo "" | docker login --username= --password-stdin ${ACR_REGISTRY} +# 或者在 CI/CD 环境中使用云效凭证配置 + +echo "Pushing Docker image: ${FULL_IMAGE_NAME}" +docker push "${FULL_IMAGE_NAME}" + +echo "Image ${FULL_IMAGE_NAME} pushed successfully." +``` + +### 4.2 `scripts/deploy_to_ack.sh` + +```bash +# deploy/scripts/deploy_to_ack.sh +#!/bin/bash + +set -euo pipefail + +# 接受参数:环境 (test/prod) 和镜像 Tag +ENV="$1" +IMAGE_TAG="$2" + +if [[ -z "$ENV" || -z "$IMAGE_TAG" ]]; then + echo "Usage: $0 " + echo "Example (test): $0 test test-abcdefg" + echo "Example (prod): $0 prod v1.0.0" + exit 1 +} + +ACR_REGISTRY="" # 替换为您的 ACR 仓库地址 +IMAGE_NAME="new-api" + +FULL_IMAGE_NAME="${ACR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + +KUBE_CONFIG_PATH="~/.kube/config" # 您的 kubeconfig 路径,CI/CD 环境中可能不同 + +echo "Deploying new-api to ${ENV} environment with image: ${FULL_IMAGE_NAME}" + +# 应用 kustomize 配置 +# 替换 deployment.yaml 中的镜像 +kustomize edit set image new-api-deployment=${FULL_IMAGE_NAME} --tag ${FULL_IMAGE_NAME} --base-dir deploy/kubernetes/overlays/${ENV} + +# 替换 ingress.yaml 中的域名 (需要您手动修改 kustomization.yaml 或直接在 base/ingress.yaml 中替换) +# kustomize edit set field --base-dir deploy/kubernetes/overlays/${ENV} + +# 部署到 Kubernetes +kustomize build deploy/kubernetes/overlays/${ENV} | kubectl apply --kubeconfig "${KUBE_CONFIG_PATH}" -f - + +echo "Deployment to ${ENV} environment successful." + +# 部署后,您可以选择运行一些 kubectl 命令进行验证 +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get pods -n new-api-${ENV} +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get svc -n new-api-${ENV} +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get ingress -n new-api-${ENV} +``` + +## 5. 后续配置 + +### 5.1 替换占位符 + +请务必替换以下文件中的占位符 (`<...>`) 为您的实际值: + +* **`deploy/docs/ACK_SETUP_GUIDE.md`**: 所有 `new-api-vpc`, `new-api-ack-cluster`, `new-api-db`, `new-api-user` 等名称,以及 RDS/Redis 的规格和连接信息。 +* **`deploy/kubernetes/base/ingress.yaml`**: `your_domain_for_new_api`, `your_ssl_certificate_id`。 +* **`deploy/kubernetes/overlays//deployment.yaml`**: `your-acr-registry`。 +* **`deploy/kubernetes/overlays//secret.yaml`**: 所有 base64 编码的敏感信息。您可以使用 `echo -n "your_value" | base64` 来生成。 +* **`deploy/scripts/build_and_push_image.sh`**: `ACR_REGISTRY`。 +* **`deploy/scripts/deploy_to_ack.sh`**: `ACR_REGISTRY`。 + +### 5.2 ACR 登录凭证 (针对脚本) + +在使用 `build_and_push_image.sh` 脚本时,确保您的 Docker 环境已经登录到 ACR。在 CI/CD 流水线中,这通常通过配置云效的 ACR 插件或配置 Docker 凭证助手来完成。 + +### 5.3 Kubeconfig 配置 (针对脚本) + +在使用 `deploy_to_ack.sh` 脚本时,确保您的 `kubectl` 已经配置了正确的 `kubeconfig` 文件,并且有权限操作目标 ACK 集群。 + +### 5.4 CI/CD 流水线集成 + +这些脚本和 Kubernetes YAML 文件将作为您云效 CI/CD 流水线中的核心步骤。您需要在云效流水线中配置相应的步骤来: + +1. **拉取代码**。 +2. **执行单元测试和代码扫描**。 +3. **构建并推送镜像** (调用 `build_and_push_image.sh`)。 +4. **部署到测试环境** (调用 `deploy_to_ack.sh`,传入 `test` 和 `test-`)。 +5. **运行集成测试**。 +6. **审批** (生产环境部署前)。 +7. **部署到生产环境** (调用 `deploy_to_ack.sh`,传入 `prod` 和 `vX.Y.Z`)。 + +这份文档和脚本将为您提供一个坚实的基础,以便在阿里云 ACK 上高效、可靠地部署和管理 `new-api` 项目。 diff --git a/deploy/kubernetes/base/configmap.yaml b/deploy/kubernetes/base/configmap.yaml new file mode 100644 index 000000000000..897c5aae9446 --- /dev/null +++ b/deploy/kubernetes/base/configmap.yaml @@ -0,0 +1,12 @@ +# deploy/kubernetes/base/configmap.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: new-api-config + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +data: + STREAMING_TIMEOUT: "300" + STREAM_SCANNER_MAX_BUFFER_MB: "64" + MAX_REQUEST_BODY_MB: "32" + # 其他非敏感环境变量 + # ERROR_LOG_ENABLED: "false" \ No newline at end of file diff --git a/deploy/kubernetes/base/hpa.yaml b/deploy/kubernetes/base/hpa.yaml new file mode 100644 index 000000000000..fc43b219bcd1 --- /dev/null +++ b/deploy/kubernetes/base/hpa.yaml @@ -0,0 +1,26 @@ +# deploy/kubernetes/base/hpa.yaml +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: new-api-hpa + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: new-api-deployment # Deployment 名称,将被 overlay 覆盖 + minReplicas: 2 # 最小副本数,确保高可用 + maxReplicas: 10 # 最大副本数 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 # 当 CPU 平均使用率达到 70% 时扩容 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 # 当内存平均使用率达到 80% 时扩容 \ No newline at end of file diff --git a/deploy/kubernetes/base/ingress.yaml b/deploy/kubernetes/base/ingress.yaml new file mode 100644 index 000000000000..4bcd86f20c85 --- /dev/null +++ b/deploy/kubernetes/base/ingress.yaml @@ -0,0 +1,36 @@ +# deploy/kubernetes/base/ingress.yaml +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: new-api-ingress + namespace: new-api # 命名空间名称,将被 overlay 覆盖 + annotations: + # 如果使用阿里云 SLB Ingress Controller,可以添加以下注解来配置 SLB + # service.beta.kubernetes.io/alicloud-loadbalancer-protocol-port: "https:443,http:80" + # service.beta.kubernetes.io/alicloud-loadbalancer-cert-id: "" # 替换为您的SSL证书ID + # service.beta.kubernetes.io/alicloud-loadbalancer-force-override-listeners: "true" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-uri: "/api/status" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-connect-port: "3000" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-interval: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-timeout: "5" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-unhealthy-threshold: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-health-check-healthy-threshold: "3" + # service.beta.kubernetes.io/alicloud-loadbalancer-spec: "slb.s1.small" # SLB实例规格 + # nginx.ingress.kubernetes.io/rewrite-target: / # 如果需要路径重写 +spec: + ingressClassName: nginx # 或者 alibaba-cloud,根据您的 Ingress Controller 类型配置 + rules: + - host: # 替换为您的实际域名,例如 new-api.yourcompany.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: new-api-service + port: + number: 80 + tls: + - hosts: + - + secretName: new-api-tls-secret # 存储TLS证书的Secret,需要手动创建或通过 Cert-manager 生成 \ No newline at end of file diff --git a/deploy/kubernetes/base/namespace.yaml b/deploy/kubernetes/base/namespace.yaml new file mode 100644 index 000000000000..42a862b479e4 --- /dev/null +++ b/deploy/kubernetes/base/namespace.yaml @@ -0,0 +1,5 @@ +# deploy/kubernetes/base/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: new-api # 命名空间名称,将被 overlay 覆盖为 new-api-test 或 new-api-prod \ No newline at end of file diff --git a/deploy/kubernetes/base/service.yaml b/deploy/kubernetes/base/service.yaml new file mode 100644 index 000000000000..4776f24e0036 --- /dev/null +++ b/deploy/kubernetes/base/service.yaml @@ -0,0 +1,14 @@ +# deploy/kubernetes/base/service.yaml +apiVersion: v1 +kind: Service +metadata: + name: new-api-service + namespace: new-api # 命名空间名称,将被 overlay 覆盖 +spec: + selector: + app: new-api + ports: + - protocol: TCP + port: 80 # Service 监听的端口 + targetPort: 3000 # Pod 实际监听的端口 + type: ClusterIP # ClusterIP 类型,仅在集群内部可访问,通过 Ingress 对外暴露 \ No newline at end of file diff --git a/deploy/kubernetes/overlays/prod/deployment.yaml b/deploy/kubernetes/overlays/prod/deployment.yaml new file mode 100644 index 000000000000..eeb7f04a82b0 --- /dev/null +++ b/deploy/kubernetes/overlays/prod/deployment.yaml @@ -0,0 +1,29 @@ +# deploy/kubernetes/overlays/prod/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-api-deployment + namespace: new-api-prod # 覆盖 base 中的命名空间 +spec: + replicas: 2 # 生产环境至少 2 个副本 + template: + spec: + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchLabels: + app: new-api + topologyKey: kubernetes.io/hostname # 将 Pod 分散到不同节点 + containers: + - name: new-api + image: /new-api:vX.Y.Z # 生产环境镜像,CI/CD 时替换为版本号 Tag + resources: + requests: + cpu: "250m" + memory: "512Mi" + limits: + cpu: "1000m" + memory: "1024Mi" \ No newline at end of file diff --git a/deploy/kubernetes/overlays/prod/kustomization.yaml b/deploy/kubernetes/overlays/prod/kustomization.yaml new file mode 100644 index 000000000000..33aba8b246a5 --- /dev/null +++ b/deploy/kubernetes/overlays/prod/kustomization.yaml @@ -0,0 +1,12 @@ +# deploy/kubernetes/overlays/prod/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: new-api-prod # 定义生产环境的命名空间 + +resources: + - ../../base # 引入基础配置 + +patches: + - path: deployment.yaml # 覆盖 Deployment 配置 + - path: secret.yaml # 覆盖 Secret 配置 \ No newline at end of file diff --git a/deploy/kubernetes/overlays/prod/secret.yaml b/deploy/kubernetes/overlays/prod/secret.yaml new file mode 100644 index 000000000000..45923286db95 --- /dev/null +++ b/deploy/kubernetes/overlays/prod/secret.yaml @@ -0,0 +1,12 @@ +# deploy/kubernetes/overlays/prod/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: new-api-secrets + namespace: new-api-prod # 覆盖 base 中的命名空间 +type: Opaque +data: + SESSION_SECRET: + CRYPTO_SECRET: + SQL_DSN: # 指向生产环境 RDS + REDIS_CONN_STRING: # 指向生产环境 Redis \ No newline at end of file diff --git a/deploy/kubernetes/overlays/test/deployment.yaml b/deploy/kubernetes/overlays/test/deployment.yaml new file mode 100644 index 000000000000..42fe8d29f28d --- /dev/null +++ b/deploy/kubernetes/overlays/test/deployment.yaml @@ -0,0 +1,20 @@ +# deploy/kubernetes/overlays/test/deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: new-api-deployment # 对应 base 中的 Deployment 名称 + namespace: new-api-test # 覆盖 base 中的命名空间 +spec: + replicas: 1 # 测试环境通常一个副本即可 + template: + spec: + containers: + - name: new-api + image: /new-api:test- # 测试环境镜像,CI/CD 时替换 + resources: + requests: + cpu: "100m" + memory: "256Mi" + limits: + cpu: "500m" + memory: "512Mi" \ No newline at end of file diff --git a/deploy/kubernetes/overlays/test/kustomization.yaml b/deploy/kubernetes/overlays/test/kustomization.yaml new file mode 100644 index 000000000000..c6cc66189eae --- /dev/null +++ b/deploy/kubernetes/overlays/test/kustomization.yaml @@ -0,0 +1,12 @@ +# deploy/kubernetes/overlays/test/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: new-api-test # 定义测试环境的命名空间 + +resources: + - ../../base # 引入基础配置 + +patches: + - path: deployment.yaml # 覆盖 Deployment 配置 + - path: secret.yaml # 覆盖 Secret 配置 \ No newline at end of file diff --git a/deploy/kubernetes/overlays/test/secret.yaml b/deploy/kubernetes/overlays/test/secret.yaml new file mode 100644 index 000000000000..13da4c5c21ab --- /dev/null +++ b/deploy/kubernetes/overlays/test/secret.yaml @@ -0,0 +1,12 @@ +# deploy/kubernetes/overlays/test/secret.yaml +apiVersion: v1 +kind: Secret +metadata: + name: new-api-secrets + namespace: new-api-test # 覆盖 base 中的命名空间 +type: Opaque +data: + SESSION_SECRET: + CRYPTO_SECRET: + SQL_DSN: # 指向测试环境 RDS + REDIS_CONN_STRING: # 指向测试环境 Redis \ No newline at end of file diff --git a/deploy/scripts/build_and_push_image.sh b/deploy/scripts/build_and_push_image.sh new file mode 100644 index 000000000000..6356058d35c8 --- /dev/null +++ b/deploy/scripts/build_and_push_image.sh @@ -0,0 +1,48 @@ +# deploy/scripts/build_and_push_image.sh +#!/bin/bash + +set -euo pipefail + +# 配置变量 +ACR_REGISTRY="" # 替换为您的 ACR 仓库地址,例如 registry.cn-hangzhou.aliyuncs.com/your-namespace +IMAGE_NAME="new-api" + +# 获取 Git Commit SHA 作为默认的测试环境 Tag +COMMIT_SHA=$(git rev-parse --short HEAD) + +# 接受参数:环境 (test/prod) 和版本号 (仅用于生产环境) +ENV="$1" +VERSION="$2" + +# 根据环境设置镜像 Tag +IMAGE_TAG="" +if [[ "$ENV" == "test" ]]; then + IMAGE_TAG="test-$COMMIT_SHA" +elif [[ "$ENV" == "prod" ]]; then + if [[ -z "$VERSION" ]]; then + echo "Error: For 'prod' environment, a version tag (e.g., v1.0.0) must be provided." + exit 1 + fi + IMAGE_TAG="$VERSION" +else + echo "Usage: $0 [version_tag_for_prod]" + echo "Example (test): $0 test" + echo "Example (prod): $0 prod v1.0.0" + exit 1 +fi + +FULL_IMAGE_NAME="${ACR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + +echo "Building Docker image: ${FULL_IMAGE_NAME}" +# 构建 Docker 镜像 (假设 Dockerfile 在项目根目录) +docker build -t "${FULL_IMAGE_NAME}" . + +echo "Logging in to ACR registry: ${ACR_REGISTRY}" +# 登录 ACR (确保您的 Docker 客户端已配置 ACR 凭证,或在此处使用 docker login) +# 例如:echo "" | docker login --username= --password-stdin ${ACR_REGISTRY} +# 或者在 CI/CD 环境中使用云效凭证配置 + +echo "Pushing Docker image: ${FULL_IMAGE_NAME}" +docker push "${FULL_IMAGE_NAME}" + +echo "Image ${FULL_IMAGE_NAME} pushed successfully." diff --git a/deploy/scripts/deploy_to_ack.sh b/deploy/scripts/deploy_to_ack.sh new file mode 100644 index 000000000000..687c2b263717 --- /dev/null +++ b/deploy/scripts/deploy_to_ack.sh @@ -0,0 +1,48 @@ +# deploy/scripts/deploy_to_ack.sh +#!/bin/bash + +set -euo pipefail + +# 接受参数:环境 (test/prod) 和镜像 Tag +ENV="$1" +IMAGE_TAG="$2" + +if [[ -z "$ENV" || -z "$IMAGE_TAG" ]]; then + echo "Usage: $0 " + echo "Example (test): $0 test test-abcdefg" + echo "Example (prod): $0 prod v1.0.0" + exit 1 +fi + +ACR_REGISTRY="" # 替换为您的 ACR 仓库地址 +IMAGE_NAME="new-api" + +FULL_IMAGE_NAME="${ACR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" + +KUBE_CONFIG_PATH="~/.kube/config" # 您的 kubeconfig 路径,CI/CD 环境中可能不同 + +echo "Deploying new-api to ${ENV} environment with image: ${FULL_IMAGE_NAME}" + +# 应用 kustomize 配置 +# 替换 deployment.yaml 中的镜像 +# kustomize edit set image new-api-deployment=${FULL_IMAGE_NAME} --tag ${FULL_IMAGE_NAME} --base-dir deploy/kubernetes/overlays/${ENV} +# Update the deployment.yaml in the overlay with the correct image tag +# This needs to be done carefully as kustomize edit set image is meant for setting the image name, not modifying existing tags in an overlay. +# A direct patch or sed might be more appropriate for updating the tag within an existing overlay deployment.yaml + +# For simplicity, we'll assume the image is directly updated in the overlay deployment.yaml by CI/CD pipeline or manually. +# A more robust solution for CI/CD would involve using `kustomize edit set image` on the base and then building, or directly patching the deployment. + +# Let's add a placeholder for image replacement in the overlay deployment.yaml using a simpler mechanism for now +# In a real CI/CD, you would use `kustomize edit set image` on the base kustomization.yaml or a more advanced patching strategy. +# For now, we'll rely on the image being set in the CI/CD pipeline. + +# Build and apply kustomize configuration +kustomize build deploy/kubernetes/overlays/${ENV} | kubectl apply --kubeconfig "${KUBE_CONFIG_PATH}" -f - + +echo "Deployment to ${ENV} environment successful." + +# 部署后,您可以选择运行一些 kubectl 命令进行验证 +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get pods -n new-api-${ENV} +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get svc -n new-api-${ENV} +# kubectl --kubeconfig "${KUBE_CONFIG_PATH}" get ingress -n new-api-${ENV} diff --git a/docs/architecture-deep-dive.md b/docs/architecture-deep-dive.md new file mode 100644 index 000000000000..32b6d336a60c --- /dev/null +++ b/docs/architecture-deep-dive.md @@ -0,0 +1,895 @@ +# new-api 架构深度解析 + +> 本文档深入分析 new-api 项目的请求处理流程、核心数据结构和算法设计,用于运维、Debug 和开发优化参考。 + +## 目录 + +- [1. 系统架构概览](#1-系统架构概览) +- [2. 请求处理流程详解](#2-请求处理流程详解) +- [3. 核心数据结构](#3-核心数据结构) +- [4. 算法设计](#4-算法设计) +- [5. 缓存机制](#5-缓存机制) +- [6. 计费系统](#6-计费系统) +- [7. 扩展开发指南](#7-扩展开发指南) + +--- + +## 1. 系统架构概览 + +### 1.1 技术栈 + +| 层级 | 技术 | 说明 | +|------|------|------| +| 后端框架 | Go 1.22+ + Gin | Web 框架和路由 | +| ORM | GORM v2 | 数据库操作 | +| 数据库 | SQLite/MySQL/PostgreSQL | 三数据库兼容 | +| 缓存 | Redis + In-Memory | 多级缓存 | +| 前端 | React 18 + Vite + Semi UI | 管理界面 | +| 前端包管理 | Bun | 推荐使用 | + +### 1.2 目录结构 + +``` +new-api/ +├── router/ # 路由定义 (API/Dashboard/Relay/Web) +├── controller/ # HTTP 请求处理器 +├── service/ # 业务逻辑层 +├── model/ # 数据模型和数据库访问 +├── relay/ # AI API 中继层 +│ ├── channel/ # 供应商适配器 (40+ providers) +│ ├── common/ # 公共结构和工具 +│ └── constant/ # Relay 常量 +├── middleware/ # HTTP 中间件 (Auth/RateLimit/Log) +├── setting/ # 配置管理 +├── common/ # 共享工具库 +├── dto/ # 数据传输对象 +├── constant/ # 常量定义 +├── types/ # 类型定义 +└── web/ # React 前端 +``` + +### 1.3 分层架构图 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 客户端请求 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Router Layer (router/) │ +│ - API Router: /api/* 管理接口 │ +│ - Relay Router: /v1/* 中继接口 │ +│ - Dashboard Router: /dashboard/* 数据看板 │ +│ - Web Router: /* 前端静态资源 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Middleware Layer (middleware/) │ +│ - TokenAuth: API Key 验证 │ +│ - UserAuth: Session 用户认证 │ +│ - Distribute: 渠道选择和负载均衡 │ +│ - RateLimit: 限流控制 │ +│ - Logger: 请求日志记录 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Controller Layer (controller/) │ +│ - relay.go: 核心中继控制器 │ +│ - user/token/channel: 资源管理 │ +│ - billing: 计费相关 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Service Layer (service/) │ +│ - channel_select.go: 渠道选择算法 │ +│ - billing.go: 计费逻辑 │ +│ - quota.go: 额度计算 │ +│ - channel_affinity.go: 渠道亲和性 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Relay Layer (relay/) │ +│ - relay_adaptor.go: 适配器工厂 │ +│ - channel/*/adaptor.go: 供应商适配器实现 │ +│ - common/relay_info.go: 中继信息结构 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Model Layer (model/) │ +│ - channel.go: 渠道模型 │ +│ - ability.go: 能力模型(分组-模型-渠道映射) │ +│ - token.go: 令牌模型 │ +│ - user.go: 用户模型 │ +│ - channel_cache.go: 渠道缓存 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Upstream Providers │ +│ - OpenAI/Azure/Claude/Gemini/AWS/... │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 请求处理流程详解 + +### 2.1 标准 Chat Completions 请求流程 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 请求生命周期流程 │ +└─────────────────────────────────────────────────────────────────────┘ + + 客户端 + │ + │ POST /v1/chat/completions + │ Authorization: Bearer sk-xxxx + ▼ +┌──────────────┐ +│ Router │ 路由匹配到 relay-router.go +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Middleware │ +│ - TokenAuth │ 验证 API Key, 获取用户信息 +│ - Set Group │ 设置用户分组到 Context +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Controller │ +│ relay.go │ Relay() 主流程 +└──────┬───────┘ + │ + ├── 1. GetAndValidateRequest() - 解析请求体 + ├── 2. GenRelayInfo() - 生成中继信息 + ├── 3. Sensitive check - 敏感词检测 + ├── 4. EstimateRequestToken() - Token 预估 + └── 5. PreConsumeBilling() - 预扣费 + │ + ▼ +┌──────────────┐ ┌──────────────────────────────────┐ +│ Service │ │ 渠道选择重试循环 │ +│ channel_ │◄────┤ retry=0 → 查询分组内优先级0渠道 │ +│ select.go │ │ ↓ │ +└──────┬───────┘ │ 失败? retry=1 → 查询优先级1渠道 │ + │ │ ↓ │ + ▼ │ 分组耗尽? → 切换到下一个分组重试 │ +┌──────────────┐ └──────────────────────────────────┘ +│ Model │ +│ ability │ GetRandomSatisfiedChannel(group, model, priority) +└──────┬───────┘ + │ + ▼ +┌──────────────┐ +│ Relay │ +│ adaptor.go │ GetAdaptor(apiType) → 获取供应商适配器 +└──────┬───────┘ + │ + ├── ConvertRequest() - 转换请求格式 + ├── SetupRequestHeader() - 设置请求头 + ├── DoRequest() - 发送 HTTP 请求 + └── DoResponse() - 处理响应 + │ + ▼ +┌──────────────┐ +│ Upstream │ HTTP POST 到上游供应商 API +└──────────────┘ + │ + ▼ +┌──────────────┐ +│ Model │ +│ consume_log │ 记录消费日志 (异步) +└──────────────┘ + │ + ▼ +┌──────────────┐ +│ Service │ PostConsumeQuota() - 实际结算 +│ billing.go │ SettleBilling() - 多退少补 +└──────┬───────┘ + │ + ▼ + 客户端 ← 返回响应 +``` + +### 2.2 关键处理阶段说明 + +#### 阶段 1: 认证与鉴权 (TokenAuth) + +```go +// middleware/auth.go - TokenAuth 流程 +func TokenAuth() { + 1. 从 Header 提取 Authorization (支持 Bearer/Anthropic/Gemini 格式) + 2. model.ValidateUserToken(key) - 验证令牌有效性 + 3. Check IP limits - IP 白名单检查 + 4. GetUserCache - 获取用户缓存信息 + 5. SetupContextForToken - 设置请求上下文 + - user_id, token_id, token_key + - user_group, token_group + - quota, unlimited_quota +} +``` + +#### 阶段 2: 请求解析与预处理 + +```go +// controller/relay.go - Relay 主流程 +func Relay(c *gin.Context, relayFormat types.RelayFormat) { + 1. GetAndValidateRequest() - 解析并验证请求体 + 2. GenRelayInfo() - 生成中继信息 + 3. Sensitive check - 敏感词检测 + 4. EstimateRequestToken() - Token 预估 + 5. PreConsumeBilling() - 预扣费 +} +``` + +#### 阶段 3: 渠道选择 (核心算法) + +```go +// service/channel_select.go +func CacheGetRandomSatisfiedChannel(param *RetryParam) (*Channel, string, error) { + if tokenGroup == "auto" { + // 自动分组模式:按优先级遍历分组 + for each autoGroup { + channel = GetRandomSatisfiedChannel(group, model, priorityRetry) + if channel != nil { return channel } + // 切换到下一个分组 + } + } else { + // 固定分组模式 + channel = GetRandomSatisfiedChannel(tokenGroup, model, retry) + } +} +``` + +#### 阶段 4: 请求中继 + +```go +// relay/relay_adaptor.go +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeOpenAI: return &openai.Adaptor{} + case constant.APITypeAnthropic: return &claude.Adaptor{} + case constant.APITypeGemini: return &gemini.Adaptor{} + // ... 40+ 适配器 + } +} + +// 适配器接口 +type Adaptor interface { + Init(info *RelayInfo) + GetRequestURL(info *RelayInfo) (string, error) + SetupRequestHeader(c *gin.Context, header *http.Header, info *RelayInfo) error + ConvertOpenAIRequest(c *gin.Context, info *RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) + DoRequest(c *gin.Context, info *RelayInfo, requestBody io.Reader) (any, error) + DoResponse(c *gin.Context, resp *http.Response, info *RelayInfo) (usage any, err *types.NewAPIError) +} +``` + +#### 阶段 5: 计费与日志 + +```go +// service/text_quota.go +func PostTextConsumeQuota(ctx, relayInfo, usage) { + 1. calculateTextQuotaSummary() - 计算额度明细 + - Prompt tokens * ModelRatio * GroupRatio + - Completion tokens * CompletionRatio + - Cache tokens * CacheRatio + - Web search / File search 额外计费 + 2. SettleBilling() - 结算(多退少补) + 3. RecordConsumeLog() - 记录消费日志 +} +``` + +--- + +## 3. 核心数据结构 + +### 3.1 Channel (渠道模型) + +```go +// model/channel.go +type Channel struct { + Id int // 渠道ID + Type int // 渠道类型 (ChannelTypeOpenAI/Anthropic/Gemini...) + Key string // API Key (支持多key换行分隔) + Status int // 状态 (Enabled/Disabled/AutoDisabled) + Name string + Weight *uint // 权重 (负载均衡) + Priority *int64 // 优先级 (重试用) + BaseURL *string // 自定义基础URL + Models string // 支持的模型 (逗号分隔) + Group string // 所属分组 (逗号分隔) + ModelMapping *string // 模型映射配置 + StatusCodeMapping *string // 状态码映射 + AutoBan *int // 是否自动禁用 + Tag *string // 标签 (批量管理) + Setting *string // 渠道设置 (JSON) + ParamOverride *string // 参数覆盖 (JSON) + HeaderOverride *string // Header覆盖 (JSON) + ChannelInfo ChannelInfo // 多Key管理信息 +} + +type ChannelInfo struct { + IsMultiKey bool // 是否多Key模式 + MultiKeySize int // Key数量 + MultiKeyStatusList map[int]int // key索引 -> 状态 + MultiKeyDisabledReason map[int]string // key索引 -> 禁用原因 + MultiKeyDisabledTime map[int]int64 // key索引 -> 禁用时间 + MultiKeyPollingIndex int // 轮询索引 + MultiKeyMode MultiKeyMode // random/polling +} +``` + +### 3.2 Ability (能力模型) + +```go +// model/ability.go +// 核心关系表: 分组 × 模型 × 渠道 的映射关系 +type Ability struct { + Group string `gorm:"primaryKey"` // 分组名 + Model string `gorm:"primaryKey"` // 模型名 + ChannelId int `gorm:"primaryKey"` // 渠道ID + Enabled bool // 是否启用 + Priority *int64 // 优先级 + Weight uint // 权重 + Tag *string // 标签 +} + +// 查询示例: 获取分组下某模型的可用渠道 +SELECT * FROM abilities +WHERE `group` = 'vip' AND model = 'gpt-4' AND enabled = 1 +ORDER BY priority DESC +``` + +### 3.3 RelayInfo (中继信息) + +```go +// relay/common/relay_info.go +type RelayInfo struct { + // 用户/令牌信息 + TokenId int + TokenKey string + TokenGroup string // 令牌指定的分组 + UserId int + UserGroup string // 用户默认分组 + UsingGroup string // 实际使用的分组(auto模式下会变化) + + // 请求信息 + OriginModelName string // 原始请求的模型名 + RelayMode int // 中继模式 (Chat/Embedding/Image...) + RelayFormat RelayFormat // 格式 (OpenAI/Claude/Gemini...) + IsStream bool + StartTime time.Time + FirstResponseTime time.Time + + // 渠道信息 + ChannelMeta *ChannelMeta + + // 计费信息 + PriceData PriceData + Billing BillingSettler + BillingSource string // wallet/subscription + + // 请求转换链 + RequestConversionChain []RelayFormat // 如: [openai, claude] +} + +type ChannelMeta struct { + ChannelType int + ChannelId int + ApiType int + ApiKey string + ChannelBaseUrl string + UpstreamModelName string // 实际发送到上游的模型名 + IsModelMapped bool // 是否经过模型映射 + SupportStreamOptions bool + ParamOverride map[string]interface{} + HeadersOverride map[string]interface{} +} +``` + +### 3.4 Token (令牌模型) + +```go +// model/token.go +type Token struct { + Id int + UserId int + Key string // 48字符唯一key + Status int // Enabled/Disabled/Exhausted/Expired + Name string + ExpiredTime int64 // -1 表示永不过期 + RemainQuota int // 剩余额度 + UnlimitedQuota bool // 是否无限额度 + ModelLimitsEnabled bool // 是否启用模型限制 + ModelLimits string // 限制的模型列表 + AllowIps *string // IP白名单 + Group string // 令牌分组 (覆盖用户分组) + CrossGroupRetry bool // 是否跨分组重试 +} +``` + +### 3.5 BillingSession (计费会话) + +```go +// service/billing_session.go +type BillingSession struct { + relayInfo *RelayInfo + funding FundingSource // WalletFunding / SubscriptionFunding + preConsumedQuota int // 预扣额度 + tokenConsumed int // 实际扣减的令牌额度 + fundingSettled bool // 资金是否已结算 + settled bool // 是否已完成 + refunded bool // 是否已退款 + mu sync.Mutex // 并发保护 +} + +type FundingSource interface { + Source() string // "wallet" / "subscription" + PreConsume(amount int) error // 预扣 + Settle(delta int) error // 结算 (delta > 0 补扣, < 0 返还) + Refund() error // 退款 +} +``` + +--- + +## 4. 算法设计 + +### 4.1 渠道选择算法 + +#### 4.1.1 加权随机选择 + +```go +// model/channel_cache.go - GetRandomSatisfiedChannel +func GetRandomSatisfiedChannel(group, model string, retry int) (*Channel, error) { + // 1. 获取该分组+模型的所有渠道 + channels := group2model2channels[group][model] + + // 2. 获取唯一优先级列表并排序 + uniquePriorities := getUniquePriorities(channels) + sort.Sort(sort.Reverse(sort.IntSlice(uniquePriorities))) + + // 3. 根据 retry 次数确定目标优先级 + // retry=0 取最高优先级, retry=1 取次高优先级... + targetPriority := uniquePriorities[min(retry, len(uniquePriorities)-1)] + + // 4. 筛选出目标优先级的渠道 + targetChannels := filterByPriority(channels, targetPriority) + + // 5. 加权随机选择 + // 权重平滑处理: 当平均权重<10时, smoothingFactor=100 + totalWeight := sumWeight * smoothingFactor + randomWeight := rand.Intn(totalWeight) + + for _, channel := range targetChannels { + randomWeight -= channel.GetWeight()*smoothingFactor + smoothingAdjustment + if randomWeight < 0 { + return channel, nil + } + } +} +``` + +#### 4.1.2 Auto 分组跨组重试 + +```go +// service/channel_select.go +func CacheGetRandomSatisfiedChannel(param *RetryParam) (*Channel, string, error) { + if param.TokenGroup == "auto" { + autoGroups := GetUserAutoGroup(userGroup) // [group1, group2, group3] + + for i := startGroupIndex; i < len(autoGroups); i++ { + autoGroup := autoGroups[i] + + // 计算当前分组内的优先级重试次数 + priorityRetry := param.GetRetry() + if i > startGroupIndex { + priorityRetry = 0 // 新分组重置优先级 + } + + channel, _ = model.GetRandomSatisfiedChannel(autoGroup, model, priorityRetry) + if channel == nil { + // 当前分组无可用渠道,切换到下一个分组 + common.SetContextKey(ctx, ContextKeyAutoGroupIndex, i+1) + param.SetRetry(0) + continue + } + return channel, autoGroup, nil + } + } +} +``` + +**重试流程示例** (假设每个分组有2个优先级, RetryTimes=3): + +``` +Retry=0: GroupA, priority0 (最高优先级) +Retry=1: GroupA, priority1 (次高优先级) +Retry=2: GroupA exhausted → GroupB, priority0 +Retry=3: GroupB, priority1 +``` + +### 4.2 多 Key 轮询算法 + +```go +// model/channel.go - GetNextEnabledKey +func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { + if !channel.ChannelInfo.IsMultiKey { + return channel.Key, 0, nil // 单Key直接返回 + } + + keys := channel.GetKeys() + + switch channel.ChannelInfo.MultiKeyMode { + case MultiKeyModeRandom: + // 随机选择一个可用key + enabledIdx := getEnabledKeyIndexes() + selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))] + return keys[selectedIdx], selectedIdx, nil + + case MultiKeyModePolling: + // 轮询选择 + lock := GetChannelPollingLock(channel.Id) // 每渠道一个锁 + lock.Lock() + defer lock.Unlock() + + start := channelInfo.MultiKeyPollingIndex + for i := 0; i < len(keys); i++ { + idx := (start + i) % len(keys) + if keyIsEnabled(idx) { + // 更新轮询索引 + channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys) + return keys[idx], idx, nil + } + } + } +} +``` + +### 4.3 Token 预估算法 + +```go +// service/token_estimator.go +func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *relaycommon.RelayInfo) (int, error) { + switch meta.TokenType { + case types.TokenTypeEstimate: + // 基于MaxTokens预估 + return estimateByMaxTokens(meta.MaxTokens), nil + + case types.TokenTypePromptTokens: + // 使用实际的PromptTokens + return meta.PromptTokens, nil + + case types.TokenTypeTokenizer: + // 使用tiktoken精确计算 + return countTokensByTokenizer(meta.CombineText, info.OriginModelName) + } +} + +// 预估策略优先级: +// 1. 如果 CountToken 启用且请求体不太大: 使用tiktoken精确计算 +// 2. 如果请求包含MaxTokens: 按MaxTokens预扣 +// 3. 否则使用默认预估 (如 1000 tokens) +``` + +--- + +## 5. 缓存机制 + +### 5.1 多级缓存架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Level 1: In-Memory Cache (channel_cache.go) │ +│ - group2model2channels: map[group][model][]channelId │ +│ - channelsIDM: map[channelId]*Channel │ +│ - 同步频率: 默认60秒 (SyncFrequency) │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ (未命中) +┌─────────────────────────────────────────────────────────┐ +│ Level 2: Redis Cache (token_cache.go, user_cache.go) │ +│ - 用户配额信息 │ +│ - 令牌信息 │ +│ - 渠道状态 │ +└─────────────────────────────────────────────────────────┘ + │ + ▼ (未命中) +┌─────────────────────────────────────────────────────────┐ +│ Level 3: Database (GORM) │ +│ - 主数据存储 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 5.2 缓存同步机制 + +```go +// model/channel_cache.go +func InitChannelCache() { + // 1. 加载所有渠道 + channels := DB.Find(&channels) + for _, channel := range channels { + channelsIDM[channel.Id] = channel + } + + // 2. 构建 group -> model -> channelIds 映射 + for _, channel := range channels { + if channel.Status != Enabled { continue } + + groups := strings.Split(channel.Group, ",") + models := strings.Split(channel.Models, ",") + + for _, group := range groups { + for _, model := range models { + group2model2channels[group][model] = append(..., channel.Id) + } + } + } + + // 3. 按优先级排序 + for each group, model2channels { + sortByPriority(model2channels) + } +} + +// 后台定期同步 +func SyncChannelCache(frequency int) { + for { + time.Sleep(frequency * time.Second) + InitChannelCache() + } +} +``` + +### 5.3 缓存一致性保证 + +| 操作 | 缓存更新策略 | +|------|-------------| +| 渠道状态变更 | `CacheUpdateChannelStatus()` 立即更新内存 + 异步更新Redis | +| 渠道编辑 | `InitChannelCache()` 全量刷新 | +| 令牌额度消耗 | `cacheDecrTokenQuota()` 更新Redis + 批量更新DB | +| 用户额度变更 | `cacheSetUserQuota()` 更新Redis + 异步写DB | + +--- + +## 6. 计费系统 + +### 6.1 计费模型 + +```go +// 额度计算公式 (text_quota.go) +Quota = ( + // 基础输入 + (PromptTokens - CacheTokens - CacheCreationTokens - ImageTokens - AudioTokens) * ModelRatio * GroupRatio + + + // 缓存输入 + CacheTokens * CacheRatio * ModelRatio * GroupRatio + + + // 缓存创建 + CacheCreationTokens * CacheCreationRatio * ModelRatio * GroupRatio + + + // 图片输入 + ImageTokens * ImageRatio * ModelRatio * GroupRatio + + + // 音频输入 (按价格) + AudioTokens * AudioPricePerMillion / 1000000 * GroupRatio + + + // 补全输出 + CompletionTokens * CompletionRatio * ModelRatio * GroupRatio + + + // 额外服务 + WebSearchCallCount * WebSearchPrice / 1000 * GroupRatio + + FileSearchCallCount * FileSearchPrice / 1000 * GroupRatio +) * OtherRatios + +// 按价格计费 (绕过token计算) +Quota = ModelPrice * QuotaPerUnit * GroupRatio +``` + +### 6.2 计费流程 + +```go +// controller/relay.go +func Relay(c *gin.Context, relayFormat) { + // 1. 预估阶段 + estimatedTokens := EstimateRequestToken(...) + priceData := ModelPriceHelper(...) // 获取价格配置 + + // 2. 预扣费 + PreConsumeBilling(c, quotaToPreConsume, relayInfo) + + defer func() { + if error != nil { + // 3. 失败退款 + relayInfo.Billing.Refund(c) + } + }() + + // 4. 执行请求 (可能重试) + for retry <= RetryTimes { + err = relayHandler(c, relayInfo) + if err == nil { break } + } + + // 5. 结算 (多退少补) + PostTextConsumeQuota(c, relayInfo, actualUsage) +} +``` + +### 6.3 计费来源优先级 + +```go +// service/billing_session.go - NewBillingSession +func NewBillingSession(c, relayInfo, preConsumedQuota) { + pref := NormalizeBillingPreference(userSetting.BillingPreference) + + switch pref { + case "subscription_only": + // 仅使用订阅 + return trySubscription() + + case "wallet_only": + // 仅使用钱包 + return tryWallet() + + case "wallet_first": + // 优先钱包,不足时回退到订阅 + session, err := tryWallet() + if err == InsufficientQuota { + return trySubscription() + } + return session + + case "subscription_first": // 默认 + // 优先订阅,无订阅时回退到钱包 + if !hasActiveSubscription() { + return tryWallet() + } + session, err := trySubscription() + if err == InsufficientQuota { + return tryWallet() + } + return session + } +} +``` + +--- + +## 7. 扩展开发指南 + +### 7.1 添加新渠道适配器 + +```go +// 1. 创建适配器文件 relay/channel/newprovider/adaptor.go +package newprovider + +type Adaptor struct { + ChannelType int +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) SetupRequestHeader(c *gin.Context, header *http.Header, info *relaycommon.RelayInfo) error { + header.Set("Authorization", "Bearer "+info.ApiKey) + return nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + // 转换请求格式 + return request, nil +} + +func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) { + return channel.DoApiRequest(a, c, info, requestBody) +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + // 处理响应 + return common_handler.OpenaiHandler(c, info, resp) +} + +// 2. 在 relay/relay_adaptor.go 注册 +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeNewProvider: + return &newprovider.Adaptor{} + } +} + +// 3. 在 constant/channel.go 添加常量 +const ChannelTypeNewProvider = 45 +const APITypeNewProvider = 45 + +// 4. 添加基础URL映射 +var ChannelBaseURLs = map[int]string{ + ChannelTypeNewProvider: "https://api.newprovider.com", +} +``` + +### 7.2 添加新中继模式 + +```go +// 1. 在 relay/constant/relay_mode.go 添加 +const ( + RelayModeChatCompletions = iota + ... + RelayModeNewFeature +) + +// 2. 在 Path2RelayMode 映射路由 +func Path2RelayMode(path string) int { + switch { + case strings.HasSuffix(path, "/new-feature"): + return RelayModeNewFeature + } +} + +// 3. 在 controller/relay.go 添加处理器 +func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + switch info.RelayMode { + case relayconstant.RelayModeNewFeature: + err = relay.NewFeatureHelper(c, info) + } +} +``` + +### 7.3 调优与监控要点 + +| 调优项 | 配置位置 | 建议值 | +|--------|----------|--------| +| 渠道同步频率 | `SYNC_FREQUENCY` | 60s (渠道多时适当增加) | +| 重试次数 | `RETRY_TIMES` | 3 | +| 信任额度阈值 | `TRUST_QUOTA` | 100000 (1美元) | +| 批量更新间隔 | `BATCH_UPDATE_INTERVAL` | 5s | +| 请求超时 | `TIMEOUT` | 根据模型调整 | + +**关键监控指标**: +- 渠道成功率 (用于自动禁用决策) +- 平均响应时间 (用于渠道选择权重调整) +- 各模型 Token 消耗分布 +- 缓存命中率 (Redis/Memory) + +--- + +## 附录: 调试技巧 + +### 启用 Debug 日志 +```bash +export GIN_MODE=debug +export DEBUG=true +``` + +### 查看渠道选择过程 +```bash +# 在日志中搜索 +"Auto selecting group" +"priorityRetry" +``` + +### 追踪单个请求 +```bash +# 每个请求有唯一的 request_id +# 在日志中搜索 request_id 可追踪完整生命周期 +``` + +--- + +*文档版本: v1.0* +*最后更新: 2025-03-30* diff --git a/docs/go-gin-tutorial.md b/docs/go-gin-tutorial.md new file mode 100644 index 000000000000..724521daeb50 --- /dev/null +++ b/docs/go-gin-tutorial.md @@ -0,0 +1,2302 @@ +# Go + Gin 框架编程完全指南 + +> 从入门到精通的 Golang 后端开发教程,结合实战经验讲解 + +--- + +## 目录 + +1. [Go 基础语法](#1-go-基础语法) +2. [核心数据结构](#2-核心数据结构) +3. [运行时管理](#3-运行时管理) +4. [并发机制](#4-并发机制) +5. [包管理](#5-包管理) +6. [高级特性](#6-高级特性) +7. [Gin 框架详解](#7-gin-框架详解) +8. [开发测试运维最佳实践](#8-开发测试运维最佳实践) + +--- + +## 1. Go 基础语法 + +### 1.1 程序结构 + +```go +package main // 包声明,每个文件必须有 + +import ( + "fmt" + "os" +) + +func main() { + // 程序入口 + fmt.Println("Hello, Go!") +} +``` + +### 1.2 变量声明 + +```go +// 完整声明 +var name string = "Go" +var age int = 15 + +// 类型推断 +var language = "Golang" // 编译器推断为 string + +// 短变量声明(函数内部常用) +count := 10 // 等价于 var count int = 10 + +// 多变量声明 +var a, b, c = 1, 2, 3 +x, y := "hello", 42 + +// 常量 +const Pi = 3.14159 +const ( + Monday = iota // 0 + Tuesday // 1 + Wednesday // 2 +) +``` + +**实战示例**(参考 new-api 配置定义): + +```go +// common/constants.go +const ( + RoleGuestUser = 0 + RoleCommonUser = 1 + RoleAdminUser = 10 + RoleRootUser = 100 +) + +const ( + UserStatusEnabled = 1 + UserStatusDisabled = 2 +) +``` + +### 1.3 基本数据类型 + +```go +// 布尔 +var flag bool = true + +// 整型 +var i8 int8 = 127 // -128 ~ 127 +var i16 int16 = 32767 // -32768 ~ 32767 +var i32 int32 = 2147483647 // int32/rune +var i64 int64 = 9223372036854775807 +var i int // 平台相关(32/64位) + +// 无符号整型 +var ui uint = 42 +var ui64 uint64 = 1 << 64 - 1 + +// 浮点 +var f32 float32 = 3.14 +var f64 float64 = 3.141592653589793 + +// 复数 +var c complex64 = 1 + 2i +var c128 complex128 = complex(1, 2) + +// 字符串 +var s string = "Go 语言" +var b byte = 'A' // uint8 别名 +var r rune = '中' // int32 别名,表示 Unicode 码点 + +// 零值 +var zeroInt int // 0 +var zeroStr string // ""(空字符串) +var zeroBool bool // false +var zeroPtr *int // nil +``` + +### 1.4 控制结构 + +```go +// if-else +if score >= 90 { + fmt.Println("A") +} else if score >= 80 { + fmt.Println("B") +} else { + fmt.Println("C") +} + +// if 带初始化语句 +if err := doSomething(); err != nil { + return err +} + +// switch +switch level { +case "debug": + log.SetLevel(log.DebugLevel) +case "info", "warn": // 多个 case + log.SetLevel(log.InfoLevel) +default: + log.SetLevel(log.ErrorLevel) +} + +// switch 无表达式(替代 if-else) +switch { +case score >= 90: + grade = "A" +case score >= 80: + grade = "B" +default: + grade = "C" +} + +// for 循环 +for i := 0; i < 10; i++ { + fmt.Println(i) +} + +// while 风格 +for condition { + // do something +} + +// 无限循环 +for { + // do something + if shouldStop { + break + } +} + +// range 遍历 +nums := []int{1, 2, 3, 4, 5} +for index, value := range nums { + fmt.Printf("index: %d, value: %d\n", index, value) +} + +// map 遍历 +m := map[string]int{"a": 1, "b": 2} +for key, value := range m { + fmt.Printf("%s: %d\n", key, value) +} +``` + +### 1.5 函数 + +```go +// 基本函数 +func add(a int, b int) int { + return a + b +} + +// 简化参数类型 +func multiply(a, b int) int { + return a * b +} + +// 多返回值(Go 特色) +func divide(a, b float64) (float64, error) { + if b == 0 { + return 0, fmt.Errorf("cannot divide by zero") + } + return a / b, nil +} + +// 命名返回值 +func split(sum int) (x, y int) { + x = sum * 4 / 9 + y = sum - x + return // 裸返回,返回命名变量 +} + +// 可变参数 +func sum(nums ...int) int { + total := 0 + for _, num := range nums { + total += num + } + return total +} + +// 函数作为参数 +func apply(nums []int, fn func(int) int) []int { + result := make([]int, len(nums)) + for i, n := range nums { + result[i] = fn(n) + } + return result +} + +// 匿名函数 +func main() { + double := func(x int) int { return x * 2 } + fmt.Println(double(5)) // 10 +} + +// 闭包 +func makeCounter() func() int { + count := 0 + return func() int { + count++ + return count + } +} +``` + +### 1.6 结构体与方法 + +```go +// 结构体定义 +type User struct { + ID int + Username string + Email string + CreatedAt time.Time +} + +// 结构体初始化 +u1 := User{ID: 1, Username: "alice"} +u2 := User{1, "bob", "bob@example.com", time.Now()} // 按字段顺序 +u3 := new(User) // 返回 *User,字段为零值 + +// 方法(值接收者) +func (u User) GetInfo() string { + return fmt.Sprintf("%s (%s)", u.Username, u.Email) +} + +// 方法(指针接收者)- 可修改原对象 +func (u *User) UpdateEmail(email string) { + u.Email = email +} + +// 嵌入式结构体(组合) +type Admin struct { + User // 匿名嵌入,继承 User 的字段和方法 + Level int +} + +admin := Admin{User: User{ID: 1, Username: "admin"}, Level: 1} +fmt.Println(admin.Username) // 直接访问嵌入字段 +``` + +**实战示例**(参考 new-api 模型定义): + +```go +// model/user.go +type User struct { + Id int `json:"id"` + Username string `json:"username" gorm:"unique;index"` + Password string `json:"password" gorm:"not null"` + Role int `json:"role" gorm:"type:int;default:1"` + Status int `json:"status" gorm:"type:int;default:1"` +} + +// 方法定义 +func (user *User) GetAccessToken() string { + if user.AccessToken == nil { + return "" + } + return *user.AccessToken +} +``` + +### 1.7 接口 + +```go +// 接口定义(隐式实现) +type Writer interface { + Write(p []byte) (n int, err error) +} + +type Reader interface { + Read(p []byte) (n int, err error) +} + +// 组合接口 +type ReadWriter interface { + Reader + Writer +} + +// 类型实现接口(无需显式声明) +type File struct { + name string +} + +func (f *File) Write(p []byte) (n int, err error) { + // 实现... + return len(p), nil +} + +func (f *File) Read(p []byte) (n int, err error) { + // 实现... + return 0, nil +} + +// File 自动实现了 Writer、Reader、ReadWriter 接口 + +// 空接口(可存储任意类型) +var any interface{} +any = 42 +any = "hello" +any = struct{ x int }{10} + +// 类型断言 +var w Writer = &File{name: "test.txt"} +if f, ok := w.(*File); ok { + fmt.Println(f.name) // 类型断言成功 +} + +// type switch +switch v := any.(type) { +case int: + fmt.Printf("int: %d\n", v) +case string: + fmt.Printf("string: %s\n", v) +default: + fmt.Printf("unknown type: %T\n", v) +} +``` + +**实战示例**(参考 new-api Relay 适配器): + +```go +// relay/channel/adapter.go +type Adaptor interface { + Init(info *relaycommon.RelayInfo) + GetRequestURL(info *relaycommon.RelayInfo) (string, error) + SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error + ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) +} + +// 不同渠道各自实现 +func (a *OpenAIAdaptor) Init(info *relaycommon.RelayInfo) { } +func (a *ClaudeAdaptor) Init(info *relaycommon.RelayInfo) { } +``` + +### 1.8 错误处理 + +```go +// 创建错误 +err := errors.New("something went wrong") +err := fmt.Errorf("wrapped error: %w", originalErr) + +// 错误链(Go 1.13+) +if errors.Is(err, targetErr) { // 检查错误链中是否包含 targetErr + // handle +} + +var ErrNotFound = errors.New("not found") +if errors.As(err, new(*NotFoundError)) { // 检查错误链中是否有特定类型 + // handle +} + +// panic 与 recover +func risky() { + panic("something bad happened") +} + +func safe() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("Recovered from: %v\n", r) + } + }() + risky() +} + +// 实战:优雅的错误处理 +func doSomething() (result string, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic recovered: %v", r) + } + }() + + // 可能 panic 的操作 + result = riskyOperation() + return +} +``` + +--- + +## 2. 核心数据结构 + +### 2.1 数组与切片 + +```go +// 数组(固定长度) +var arr [5]int = [5]int{1, 2, 3, 4, 5} +arr2 := [...]int{1, 2, 3} // 长度由初始化值决定 + +// 切片(动态数组) +s := []int{1, 2, 3} +s2 := make([]int, 5) // len=5, cap=5 +s3 := make([]int, 3, 10) // len=3, cap=10 + +// 切片操作 +s = append(s, 4, 5) // 追加元素 +s = append(s, []int{6, 7}...) // 追加切片 + +// 切片的切片(引用同底层数组) +original := []int{1, 2, 3, 4, 5} +sub := original[1:3] // [2, 3] + +// copy +src := []int{1, 2, 3} +dst := make([]int, len(src)) +copy(dst, src) + +// 切片内部结构(理解内存布局) +type SliceHeader struct { + Data uintptr // 指向底层数组的指针 + Len int // 长度 + Cap int // 容量 +} +``` + +### 2.2 Map + +```go +// 创建 +m := make(map[string]int) +m["one"] = 1 + +// 字面量创建 +m2 := map[string]int{ + "one": 1, + "two": 2, +} + +// 访问 +v := m["one"] // 1 +v, ok := m["three"] // 0, false(不存在) + +// 删除 +delete(m, "one") + +// 遍历 +for k, v := range m { + fmt.Printf("%s: %d\n", k, v) +} + +// 注意:map 不是并发安全的 +// 并发使用需要加锁或使用 sync.Map +``` + +**实战示例**(参考 new-api 渠道缓存): + +```go +// model/channel_cache.go +var channelCache = make(map[string]*Channel) +var channelCacheMutex sync.RWMutex + +func GetChannelFromCache(id string) *Channel { + channelCacheMutex.RLock() + defer channelCacheMutex.RUnlock() + return channelCache[id] +} + +func UpdateChannelCache(channel *Channel) { + channelCacheMutex.Lock() + defer channelCacheMutex.Unlock() + channelCache[channel.ID] = channel +} +``` + +### 2.3 结构体标签 + +```go +type Person struct { + Name string `json:"name" db:"user_name"` + Age int `json:"age,omitempty"` // omitempty: 零值时忽略 + Email string `json:"email" validate:"email"` + Password string `json:"-"` // -: 不参与序列化 + CreatedAt time.Time `json:"created_at"` +} + +// 反射读取标签 +import "reflect" + +t := reflect.TypeOf(Person{}) +field, _ := t.FieldByName("Name") +tag := field.Tag.Get("json") // "name" +``` + +### 2.4 嵌入类型与组合 + +```go +// 接口组合 +type ReadWriter interface { + Reader + Writer +} + +// 结构体嵌入(继承行为) +type Animal struct { + Name string +} + +func (a Animal) Speak() string { + return "Some sound" +} + +type Dog struct { + Animal // 匿名嵌入 + Breed string +} + +// Dog 自动拥有 Speak 方法 +// 可以重写: +func (d Dog) Speak() string { + return "Woof!" +} + +d := Dog{Animal: Animal{Name: "Buddy"}, Breed: "Golden"} +fmt.Println(d.Name) // Buddy(直接访问嵌入字段) +fmt.Println(d.Speak()) // Woof!(调用重写的方法) +``` + +### 2.5 泛型(Go 1.18+) + +```go +// 泛型函数 +func Max[T constraints.Ordered](a, b T) T { + if a > b { + return a + } + return b +} + +// 使用 +maxInt := Max[int](10, 20) // 20 +maxFloat := Max(3.14, 2.71) // 类型推断 + +// 泛型类型 +type Stack[T any] struct { + items []T +} + +func (s *Stack[T]) Push(item T) { + s.items = append(s.items, item) +} + +func (s *Stack[T]) Pop() T { + var zero T + if len(s.items) == 0 { + return zero + } + item := s.items[len(s.items)-1] + s.items = s.items[:len(s.items)-1] + return item +} + +// 使用 +intStack := Stack[int]{} +intStack.Push(10) +intStack.Push(20) + +// 类型约束 +type Number interface { + constraints.Integer | constraints.Float +} + +func Sum[T Number](nums []T) T { + var sum T + for _, n := range nums { + sum += n + } + return sum +} +``` + +--- + +## 3. 运行时管理 + +### 3.1 内存管理 + +```go +// 栈 vs 堆 +// Go 编译器会自动决定变量分配在栈还是堆 + +func stackAlloc() int { + x := 42 // 可能分配在栈上 + return x +} + +func heapAlloc() *int { + x := 42 // 逃逸到堆(返回了指针) + return &x +} + +// 手动内存分配(极少数情况需要) +import "unsafe" + +// 查看逃逸分析 +go build -gcflags="-m" main.go +``` + +### 3.2 垃圾回收 + +```go +// GC 调优 +import "runtime" + +// 设置 GC 目标百分比(默认 100) +// 100 表示内存增长 100% 时触发 GC +runtime.SetGCPercent(100) + +// 手动触发 GC +runtime.GC() + +// 查看 GC 统计 +import "runtime/debug" + +var m runtime.MemStats +runtime.ReadMemStats(&m) +fmt.Printf("Alloc = %v KB\n", m.Alloc/1024) +fmt.Printf("TotalAlloc = %v KB\n", m.TotalAlloc/1024) +fmt.Printf("Sys = %v KB\n", m.Sys/1024) +fmt.Printf("NumGC = %v\n", m.NumGC) + +// 设置内存限制(Go 1.19+) +debug.SetMemoryLimit(10 << 30) // 10 GB +``` + +### 3.3 性能分析 + +```go +import ( + "net/http" + _ "net/http/pprof" +) + +func main() { + go func() { + log.Println(http.ListenAndServe("localhost:6060", nil)) + }() + // ... +} +``` + +**pprof 使用**: + +```bash +# CPU 分析 +go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 + +# 内存分析 +go tool pprof http://localhost:6060/debug/pprof/heap + +# 查看 top 消耗 +top10 + +# 生成火焰图 +web +``` + +--- + +## 4. 并发机制 + +### 4.1 Goroutine + +```go +// 创建 Goroutine +go func() { + fmt.Println("Running in goroutine") +}() + +// 带参数的 goroutine +func worker(id int) { + fmt.Printf("Worker %d starting\n", id) + time.Sleep(time.Second) + fmt.Printf("Worker %d done\n", id) +} + +for i := 1; i <= 3; i++ { + go worker(i) +} + +time.Sleep(2 * time.Second) // 等待 goroutine 完成 +``` + +### 4.2 Channel(通道) + +```go +// 创建 channel +ch := make(chan int) // 无缓冲 channel +ch := make(chan int, 10) // 有缓冲 channel + +// 发送和接收 +ch <- 42 // 发送 +v := <-ch // 接收 + +// 关闭 channel +close(ch) + +// 检查 channel 是否关闭 +v, ok := <-ch +if !ok { + // channel 已关闭 +} + +// range 遍历 channel +for v := range ch { + fmt.Println(v) +} + +// select 多路复用 +select { +case v1 := <-ch1: + fmt.Println("Received from ch1:", v1) +case v2 := <-ch2: + fmt.Println("Received from ch2:", v2) +case ch3 <- 100: + fmt.Println("Sent to ch3") +default: + fmt.Println("No channel ready") +} + +// 超时控制 +select { +case result := <-ch: + fmt.Println("Result:", result) +case <-time.After(3 * time.Second): + fmt.Println("Timeout!") +} +``` + +### 4.3 WaitGroup + +```go +import "sync" + +var wg sync.WaitGroup + +for i := 0; i < 3; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + fmt.Printf("Worker %d starting\n", id) + time.Sleep(time.Second) + fmt.Printf("Worker %d done\n", id) + }(i) +} + +wg.Wait() // 等待所有 goroutine 完成 +fmt.Println("All workers done") +``` + +**实战示例**(参考 new-api 并发处理): + +```go +// controller/task.go +if common.IsMasterNode && constant.UpdateTask { + gopool.Go(func() { + controller.UpdateMidjourneyTaskBulk() + }) + gopool.Go(func() { + controller.UpdateTaskBulk() + }) +} +``` + +### 4.4 Mutex(互斥锁) + +```go +// 互斥锁 +var mu sync.Mutex +var count int + +func increment() { + mu.Lock() + defer mu.Unlock() + count++ +} + +// 读写锁(读多写少场景) +var rwMu sync.RWMutex +var data map[string]string + +func read(key string) string { + rwMu.RLock() + defer rwMu.RUnlock() + return data[key] +} + +func write(key, value string) { + rwMu.Lock() + defer rwMu.Unlock() + data[key] = value +} +``` + +**实战示例**: + +```go +// common/constants.go +var OptionMap map[string]string +var OptionMapRWMutex sync.RWMutex + +// 读操作 +func GetOption(key string) string { + OptionMapRWMutex.RLock() + defer OptionMapRWMutex.RUnlock() + return OptionMap[key] +} + +// 写操作 +func SetOption(key, value string) { + OptionMapRWMutex.Lock() + defer OptionMapRWMutex.Unlock() + OptionMap[key] = value +} +``` + +### 4.5 Context + +```go +import "context" + +// 创建 context +ctx := context.Background() +ctx := context.TODO() + +// 带取消的 context +ctx, cancel := context.WithCancel(context.Background()) +defer cancel() + +// 带超时的 context +ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) +defer cancel() + +// 带截止时间的 context +ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(3*time.Second)) +defer cancel() + +// 传递值 +ctx := context.WithValue(context.Background(), "key", "value") +value := ctx.Value("key") + +// 实战:超时控制 +func callAPI(ctx context.Context) error { + req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.example.com", nil) + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + if err := callAPI(ctx); err != nil { + fmt.Println("Error:", err) + } +} +``` + +**实战示例**(Gin 中使用 Context): + +```go +// middleware/auth.go +func TokenAuth() func(c *gin.Context) { + return func(c *gin.Context) { + // 从 gin.Context 获取请求上下文 + ctx := c.Request.Context() + + // 设置超时 + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + // 使用 context 进行数据库查询 + token, err := model.ValidateUserTokenWithContext(ctx, key) + // ... + } +} +``` + +### 4.6 原子操作 + +```go +import "sync/atomic" + +var counter int64 + +// 原子增加 +atomic.AddInt64(&counter, 1) + +// 原子读取 +value := atomic.LoadInt64(&counter) + +// 原子存储 +atomic.StoreInt64(&counter, 100) + +// CAS 操作 +swapped := atomic.CompareAndSwapInt64(&counter, 100, 200) +``` + +--- + +## 5. 包管理 + +### 5.1 Go Modules + +```bash +# 初始化模块 +go mod init github.com/username/project + +# 添加依赖 +go get github.com/gin-gonic/gin +go get github.com/gin-gonic/gin@v1.9.1 # 指定版本 + +# 更新依赖 +go get -u ./... +go get -u github.com/gin-gonic/gin # 更新单个包 + +# 清理未使用依赖 +go mod tidy + +# 下载依赖 +go mod download + +# 查看依赖树 +go mod graph + +# 供应商模式(vendor) +go mod vendor +``` + +### 5.2 go.mod 文件 + +```go +module github.com/example/myproject + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.9.1 + github.com/go-redis/redis/v8 v8.11.5 + gorm.io/driver/mysql v1.4.3 + gorm.io/gorm v1.25.2 +) + +require ( + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + // ... +) +``` + +### 5.3 包组织规范 + +``` +myproject/ +├── go.mod +├── main.go # 入口 +├── internal/ # 私有代码 +│ ├── config/ # 配置 +│ ├── models/ # 数据模型 +│ └── utils/ # 工具函数 +├── pkg/ # 公共库(可被外部使用) +│ ├── logger/ +│ └── errors/ +├── api/ # API 定义 +├── cmd/ # 可执行程序 +│ ├── server/ +│ └── worker/ +└── web/ # 前端资源 +``` + +--- + +## 6. 高级特性 + +### 6.1 反射 + +```go +import "reflect" + +// 获取类型信息 +t := reflect.TypeOf(User{}) +fmt.Println(t.Name()) // User +fmt.Println(t.Kind()) // struct + +// 遍历结构体字段 +for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fmt.Printf("Field: %s, Type: %s, Tag: %s\n", + field.Name, field.Type, field.Tag) +} + +// 动态调用方法 +v := reflect.ValueOf(&User{}) +method := v.MethodByName("UpdateEmail") +args := []reflect.Value{reflect.ValueOf("new@email.com")} +method.Call(args) + +// 实战:结构体拷贝 +func CopyStruct(src, dst interface{}) { + srcVal := reflect.ValueOf(src).Elem() + dstVal := reflect.ValueOf(dst).Elem() + + for i := 0; i < srcVal.NumField(); i++ { + dstField := dstVal.Field(i) + if dstField.CanSet() { + dstField.Set(srcVal.Field(i)) + } + } +} +``` + +### 6.2 Unsafe 包 + +```go +import "unsafe" + +// 指针转换 +var x int64 = 42 +ptr := unsafe.Pointer(&x) + +// 计算结构体偏移量 +type T struct { + A int8 + B int64 +} +t := T{} +offsetB := unsafe.Offsetof(t.B) // 8(考虑内存对齐) + +// 字符串与字节切片零拷贝转换 +func StringToBytes(s string) []byte { + return *(*[]byte)(unsafe.Pointer(&s)) +} + +// 警告:unsafe 绕过类型系统,使用需谨慎! +``` + +### 6.3 CGO + +```go +package main + +/* +#include +void hello() { + printf("Hello from C!\n"); +} +*/ +import "C" + +func main() { + C.hello() +} +``` + +### 6.4 编译标签 + +```go +// +build linux + +package main +// Linux 特定代码 +``` + +```go +// +build windows + +package main +// Windows 特定代码 +``` + +**实战示例**: + +```go +// common/system_monitor_unix.go +//go:build !windows +// +build !windows + +package common + +func GetSystemInfo() (*SystemInfo, error) { + // Unix 系统实现 +} +``` + +```go +// common/system_monitor_windows.go +//go:build windows +// +build windows + +package common + +func GetSystemInfo() (*SystemInfo, error) { + // Windows 系统实现 +} +``` + +--- + +## 7. Gin 框架详解 + +### 7.1 快速开始 + +```go +package main + +import ( + "net/http" + "github.com/gin-gonic/gin" +) + +func main() { + // 创建默认路由(带 Logger 和 Recovery 中间件) + r := gin.Default() + + // 或者创建纯净路由 + // r := gin.New() + + // 定义路由 + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) + }) + + // 启动服务 + r.Run(":8080") +} +``` + +### 7.2 路由定义 + +```go +// HTTP 方法 +r.GET("/users", getUsers) +r.POST("/users", createUser) +r.PUT("/users/:id", updateUser) +r.DELETE("/users/:id", deleteUser) +r.PATCH("/users/:id", patchUser) +r.HEAD("/users", headUsers) +r.OPTIONS("/users", optionsUsers) + +// 路由参数 +r.GET("/users/:id", func(c *gin.Context) { + id := c.Param("id") + c.JSON(200, gin.H{"id": id}) +}) + +// 查询参数 +r.GET("/search", func(c *gin.Context) { + query := c.Query("q") // ?q=keyword + page := c.DefaultQuery("page", "1") // 默认值 + tags := c.QueryArray("tag") // ?tag=a&tag=b + + c.JSON(200, gin.H{ + "query": query, + "page": page, + "tags": tags, + }) +}) + +// 路由组 +api := r.Group("/api") +{ + v1 := api.Group("/v1") + { + v1.GET("/users", getUsers) + v1.GET("/posts", getPosts) + } + + v2 := api.Group("/v2") + { + v2.GET("/users", getUsersV2) + } +} + +// 路由组中间件 +authorized := r.Group("/admin", AuthMiddleware()) +{ + authorized.GET("/dashboard", dashboard) +} +``` + +### 7.3 请求处理 + +```go +// 绑定 JSON +func createUser(c *gin.Context) { + var user struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Age int `json:"age" binding:"gte=0,lte=130"` + } + + if err := c.ShouldBindJSON(&user); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // 处理用户创建... + c.JSON(http.StatusCreated, user) +} + +// 绑定表单 +func uploadForm(c *gin.Context) { + var form struct { + Username string `form:"username" binding:"required"` + Password string `form:"password" binding:"required,min=6"` + } + + if err := c.ShouldBind(&form); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, form) +} + +// 绑定 URI +func getUser(c *gin.Context) { + var uri struct { + ID int `uri:"id" binding:"required,min=1"` + } + + if err := c.ShouldBindUri(&uri); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + + c.JSON(200, gin.H{"id": uri.ID}) +} + +// 文件上传 +func uploadFile(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + defer file.Close() + + // 保存文件 + c.SaveUploadedFile(header, "/path/to/save/"+header.Filename) + + c.JSON(200, gin.H{"filename": header.Filename}) +} +``` + +### 7.4 响应处理 + +```go +// JSON 响应 +c.JSON(200, gin.H{ + "message": "success", + "data": user, +}) + +// XML 响应 +c.XML(200, user) + +// YAML 响应 +c.YAML(200, user) + +// 字符串 +c.String(200, "Hello %s", name) + +// HTML +c.HTML(200, "index.tmpl", gin.H{ + "title": "Main website", +}) + +// 文件 +c.File("/path/to/file.txt") + +// 重定向 +c.Redirect(301, "https://example.com") + +// 设置 Cookie +c.SetCookie("session_id", "abc123", 3600, "/", "localhost", false, true) + +// 读取 Cookie +value, err := c.Cookie("session_id") +``` + +### 7.5 中间件 + +```go +// 自定义中间件 +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + // 请求前处理 + start := time.Now() + path := c.Request.URL.Path + + // 继续处理请求 + c.Next() + + // 请求后处理 + duration := time.Since(start) + status := c.Writer.Status() + + log.Printf("%s %s %d %v", c.Request.Method, path, status, duration) + } +} + +// 认证中间件 +func AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) + return + } + + // 验证 token... + userID, err := validateToken(token) + if err != nil { + c.AbortWithStatusJSON(401, gin.H{"error": "invalid token"}) + return + } + + // 设置用户信息到上下文 + c.Set("user_id", userID) + c.Next() + } +} + +// 使用中间件 +r.Use(Logger()) +r.Use(AuthMiddleware()) + +// 特定路由中间件 +r.GET("/protected", AuthMiddleware(), handler) + +// 中间件链 +r.GET("/chain", middleware1, middleware2, handler) +``` + +**实战示例**(参考 new-api 中间件): + +```go +// middleware/auth.go +func TokenAuth() func(c *gin.Context) { + return func(c *gin.Context) { + key := c.Request.Header.Get("Authorization") + if key == "" { + abortWithOpenAiMessage(c, http.StatusUnauthorized, "未提供 Authorization 请求头") + return + } + + // 解析 token key + key = strings.TrimPrefix(key, "sk-") + parts := strings.Split(key, "-") + key = parts[0] + + // 验证 token + token, err := model.ValidateUserToken(key) + if err != nil { + abortWithOpenAiMessage(c, http.StatusUnauthorized, err.Error()) + return + } + + // 设置上下文 + c.Set("id", token.UserId) + c.Set("token_id", token.Id) + c.Set("token_key", token.Key) + + c.Next() + } +} + +// middleware/distributor.go +func Distributor() func(c *gin.Context) { + return func(c *gin.Context) { + // 获取请求模型 + modelName := c.GetString("model") + userGroup := c.GetString("group") + + // 选择渠道 + channel, err := service.CacheGetRandomSatisfiedChannel( + modelName, + userGroup, + ) + if err != nil { + c.JSON(500, gin.H{"error": err.Error()}) + c.Abort() + return + } + + // 设置渠道信息 + c.Set("channel", channel) + c.Next() + } +} +``` + +### 7.6 错误处理与恢复 + +```go +// 全局错误恢复 +r.Use(gin.CustomRecovery(func(c *gin.Context, err any) { + log.Printf("Panic recovered: %v", err) + c.JSON(500, gin.H{ + "error": "Internal server error", + "request_id": c.GetString("request_id"), + }) +})) + +// 统一错误处理 +func APIError(c *gin.Context, message string) { + c.JSON(200, gin.H{ + "success": false, + "message": message, + }) +} + +func APISuccess(c *gin.Context, data interface{}) { + c.JSON(200, gin.H{ + "success": true, + "data": data, + }) +} +``` + +### 7.7 模板渲染 + +```go +import "html/template" + +// 加载模板 +r.LoadHTMLGlob("templates/*") + +// 或使用自定义模板函数 +func formatDate(t time.Time) string { + return t.Format("2006-01-02") +} + +r.SetFuncMap(template.FuncMap{ + "formatDate": formatDate, +}) + +r.LoadHTMLFiles("templates/index.tmpl") + +// 渲染 +c.HTML(200, "index.tmpl", gin.H{ + "title": "首页", + "users": users, +}) +``` + +### 7.8 静态文件 + +```go +// 静态文件服务 +r.Static("/static", "./static") + +// 单个文件 +r.StaticFile("/favicon.ico", "./resources/favicon.ico") + +// FS 嵌入(Go 1.16+) +import "embed" + +//go:embed web/dist/* +var staticFS embed.FS + +r.StaticFS("/static", http.FS(staticFS)) +``` + +### 7.9 优雅关闭 + +```go +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gin-gonic/gin" +) + +func main() { + r := gin.Default() + r.GET("/", func(c *gin.Context) { + time.Sleep(5 * time.Second) + c.String(200, "Welcome Gin Server") + }) + + srv := &http.Server{ + Addr: ":8080", + Handler: r, + } + + // 启动服务(goroutine) + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + // 等待中断信号 + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + log.Println("Shutting down server...") + + // 优雅关闭 + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + log.Println("Server exiting") +} +``` + +### 7.10 高级模式:控制器分层 + +```go +// controller/base.go +package controller + +import "github.com/gin-gonic/gin" + +type BaseController struct{} + +func (b *BaseController) JSON(c *gin.Context, code int, data interface{}) { + c.JSON(code, data) +} + +func (b *BaseController) Success(c *gin.Context, data interface{}) { + c.JSON(200, gin.H{ + "success": true, + "data": data, + }) +} + +func (b *BaseController) Error(c *gin.Context, message string) { + c.JSON(200, gin.H{ + "success": false, + "message": message, + }) +} + +// controller/user.go +type UserController struct { + BaseController +} + +func (u *UserController) Get(c *gin.Context) { + id := c.Param("id") + user, err := userService.GetByID(id) + if err != nil { + u.Error(c, err.Error()) + return + } + u.Success(c, user) +} + +func (u *UserController) Create(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + u.Error(c, err.Error()) + return + } + + user, err := userService.Create(req) + if err != nil { + u.Error(c, err.Error()) + return + } + u.Success(c, user) +} + +// router +userController := &controller.UserController{} +r.GET("/users/:id", userController.Get) +r.POST("/users", userController.Create) +``` + +--- + +## 8. 开发测试运维最佳实践 + +### 8.1 项目结构规范 + +``` +project/ +├── cmd/ # 可执行程序入口 +│ ├── api/ +│ │ └── main.go +│ └── worker/ +│ └── main.go +├── internal/ # 私有代码 +│ ├── config/ # 配置管理 +│ │ ├── config.go +│ │ └── config.yaml +│ ├── domain/ # 领域模型 +│ │ ├── user.go +│ │ └── order.go +│ ├── repository/ # 数据访问 +│ │ ├── user_repo.go +│ │ └── order_repo.go +│ ├── service/ # 业务逻辑 +│ │ ├── user_service.go +│ │ └── order_service.go +│ ├── handler/ # HTTP 处理器 +│ │ ├── user_handler.go +│ │ └── order_handler.go +│ └── middleware/ # 中间件 +│ ├── auth.go +│ └── logger.go +├── pkg/ # 公共库 +│ ├── logger/ +│ ├── errors/ +│ └── utils/ +├── api/ # API 定义 +│ ├── proto/ # Protocol Buffers +│ └── swagger/ # Swagger 文档 +├── web/ # 前端代码 +├── configs/ # 配置文件 +├── deployments/ # 部署配置 +│ ├── docker/ +│ └── k8s/ +├── scripts/ # 脚本 +├── docs/ # 文档 +├── tests/ # 测试 +├── Makefile +├── Dockerfile +├── docker-compose.yml +├── go.mod +└── README.md +``` + +### 8.2 配置管理 + +```go +// internal/config/config.go +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + Redis RedisConfig `mapstructure:"redis"` + Log LogConfig `mapstructure:"log"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` +} + +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + DSN string `mapstructure:"dsn"` + MaxOpenConns int `mapstructure:"max_open_conns"` + MaxIdleConns int `mapstructure:"max_idle_conns"` +} + +var C Config + +func Init(configPath string) error { + viper.SetConfigFile(configPath) + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return err + } + + if err := viper.Unmarshal(&C); err != nil { + return err + } + + return nil +} +``` + +```yaml +# configs/config.yaml +server: + port: 8080 + mode: "release" # debug/release + +database: + driver: "mysql" + dsn: "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + max_open_conns: 100 + max_idle_conns: 10 + +redis: + addr: "localhost:6379" + password: "" + db: 0 + +log: + level: "info" + format: "json" + output: "stdout" +``` + +### 8.3 日志管理 + +```go +// pkg/logger/logger.go +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +var log *zap.Logger + +func Init(level string) error { + config := zap.NewProductionConfig() + + l, err := zapcore.ParseLevel(level) + if err != nil { + return err + } + config.Level = zap.NewAtomicLevelAt(l) + + log, err = config.Build() + if err != nil { + return err + } + + return nil +} + +func Info(msg string, fields ...zap.Field) { + log.Info(msg, fields...) +} + +func Error(msg string, fields ...zap.Field) { + log.Error(msg, fields...) +} + +func With(fields ...zap.Field) *zap.Logger { + return log.With(fields...) +} +``` + +### 8.4 单元测试 + +```go +// internal/service/user_service_test.go +package service + +import ( + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// Mock 仓库 +type MockUserRepo struct { + mock.Mock +} + +func (m *MockUserRepo) GetByID(id string) (*User, error) { + args := m.Called(id) + return args.Get(0).(*User), args.Error(1) +} + +func TestUserService_GetByID(t *testing.T) { + // 准备 + mockRepo := new(MockUserRepo) + service := NewUserService(mockRepo) + + expected := &User{ID: "1", Username: "test"} + mockRepo.On("GetByID", "1").Return(expected, nil) + + // 执行 + user, err := service.GetByID("1") + + // 验证 + assert.NoError(t, err) + assert.Equal(t, expected, user) + mockRepo.AssertExpectations(t) +} + +// 表格驱动测试 +func TestCalculate(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive", 1, 2, 3}, + {"negative", -1, -2, -3}, + {"zero", 0, 0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Calculate(tt.a, tt.b) + assert.Equal(t, tt.expected, result) + }) + } +} + +// HTTP 测试 +func TestUserHandler_Get(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/users/:id", userHandler.Get) + + req, _ := http.NewRequest("GET", "/users/1", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + assert.Equal(t, 200, w.Code) + // 验证响应体... +} +``` + +### 8.5 集成测试 + +```go +// tests/integration/user_test.go +package integration + +import ( + "testing" + "github.com/stretchr/testify/suite" +) + +type UserSuite struct { + suite.Suite + db *sql.DB +} + +func (s *UserSuite) SetupSuite() { + // 初始化测试数据库 + s.db = setupTestDB() +} + +func (s *UserSuite) TearDownSuite() { + s.db.Close() +} + +func (s *UserSuite) TestCreateUser() { + // 测试创建用户 +} + +func (s *UserSuite) TestGetUser() { + // 测试获取用户 +} + +func TestUserSuite(t *testing.T) { + suite.Run(t, new(UserSuite)) +} +``` + +### 8.6 Docker 部署 + +```dockerfile +# Dockerfile +# 构建阶段 +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# 安装依赖 +RUN apk add --no-cache git + +# 下载依赖 +COPY go.mod go.sum ./ +RUN go mod download + +# 编译 +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api + +# 运行阶段 +FROM alpine:latest + +RUN apk --no-cache add ca-certificates + +WORKDIR /root/ + +# 从构建阶段复制二进制文件 +COPY --from=builder /app/main . +COPY --from=builder /app/configs ./configs + +# 暴露端口 +EXPOSE 8080 + +# 运行 +CMD ["./main"] +``` + +```yaml +# docker-compose.yml +version: '3.8' + +services: + api: + build: . + ports: + - "8080:8080" + environment: + - SERVER_PORT=8080 + - DB_HOST=mysql + - DB_PORT=3306 + depends_on: + - mysql + - redis + networks: + - app-network + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: rootpass + MYSQL_DATABASE: myapp + volumes: + - mysql_data:/var/lib/mysql + ports: + - "3306:3306" + networks: + - app-network + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + networks: + - app-network + +volumes: + mysql_data: + +networks: + app-network: + driver: bridge +``` + +### 8.7 Makefile + +```makefile +.PHONY: build test clean run docker-build docker-run + +# 变量 +BINARY_NAME=myapp +DOCKER_IMAGE=myapp:latest + +# 构建 +build: + go build -o bin/$(BINARY_NAME) ./cmd/api + +# 测试 +test: + go test -v ./... + +test-coverage: + go test -cover -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# 清理 +clean: + rm -rf bin/ + rm -f coverage.out coverage.html + +# 运行 +run: + go run ./cmd/api + +# 开发模式(热重载) +dev: + air -c .air.toml + +# 代码检查 +lint: + golangci-lint run + +# 格式化 +fmt: + go fmt ./... + +# 依赖管理 +deps: + go mod download + go mod tidy + +# Docker +docker-build: + docker build -t $(DOCKER_IMAGE) . + +docker-run: + docker run -p 8080:8080 $(DOCKER_IMAGE) + +docker-compose-up: + docker-compose up -d + +docker-compose-down: + docker-compose down + +# 数据库迁移 +migrate-up: + migrate -path migrations -database "mysql://user:pass@/dbname" up + +migrate-down: + migrate -path migrations -database "mysql://user:pass@/dbname" down + +# 生成代码(如果有使用) +generate: + go generate ./... + +# 全部检查 +check: fmt lint test + +# 帮助 +help: + @echo "Available targets:" + @echo " build - Build the binary" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " run - Run the application" + @echo " dev - Run with hot reload" + @echo " lint - Run linter" + @echo " fmt - Format code" + @echo " docker-build - Build Docker image" + @echo " docker-run - Run Docker container" +``` + +### 8.8 CI/CD (GitHub Actions) + +```yaml +# .github/workflows/ci.yml +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test + ports: + - 3306:3306 + options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + + redis: + image: redis:7 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + + - name: Download dependencies + run: go mod download + + - name: Run linter + uses: golangci/golangci-lint-action@v3 + with: + version: latest + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + env: + DB_HOST: localhost + REDIS_HOST: localhost + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + + build: + runs-on: ubuntu-latest + needs: test + + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: make build + + - name: Build Docker image + run: make docker-build +``` + +### 8.9 性能优化 + +```go +// 1. 对象池(减少 GC 压力) +var pool = sync.Pool{ + New: func() interface{} { + return make([]byte, 1024) + }, +} + +func process() { + buf := pool.Get().([]byte) + defer pool.Put(buf) + // 使用 buf... +} + +// 2. 预分配切片容量 +data := make([]int, 0, 100) // 预分配容量 +for i := 0; i < 100; i++ { + data = append(data, i) // 避免多次扩容 +} + +// 3. 字符串 Builder(避免多次分配) +var builder strings.Builder +builder.Grow(100) // 预分配 +for i := 0; i < 100; i++ { + builder.WriteString("data") +} +result := builder.String() + +// 4. 并行处理 +func processBatch(items []Item) { + var wg sync.WaitGroup + numWorkers := runtime.NumCPU() + chunkSize := len(items) / numWorkers + + for i := 0; i < numWorkers; i++ { + wg.Add(1) + start := i * chunkSize + end := start + chunkSize + if i == numWorkers-1 { + end = len(items) + } + + go func(chunk []Item) { + defer wg.Done() + for _, item := range chunk { + process(item) + } + }(items[start:end]) + } + + wg.Wait() +} +``` + +### 8.10 安全最佳实践 + +```go +// 1. 防止 SQL 注入(使用参数化查询) +// ✅ 正确 +rows, err := db.Query("SELECT * FROM users WHERE id = ?", userID) + +// ❌ 错误 +rows, err := db.Query(fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID)) + +// 2. 防止 XSS(转义输出) +import "html" +escaped := html.EscapeString(userInput) + +// 3. 密码哈希 +import "golang.org/x/crypto/bcrypt" + +hash, _ := bcrypt.GenerateFromPassword(password, bcrypt.DefaultCost) +bcrypt.CompareHashAndPassword(hash, password) + +// 4. JWT 安全 +token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) +tokenString, _ := token.SignedString([]byte(secretKey)) + +// 验证时检查签名方法 +token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(secretKey), nil +}) + +// 5. 限流 +import "golang.org/x/time/rate" + +limiter := rate.NewLimiter(rate.Every(time.Second), 10) // 每秒 10 个请求 + +func handler(c *gin.Context) { + if !limiter.Allow() { + c.AbortWithStatus(429) // Too Many Requests + return + } + // 处理请求... +} + +// 6. CORS 配置 +config := cors.Config{ + AllowOrigins: []string{"https://example.com"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, +} +r.Use(cors.New(config)) +``` + +--- + +## 结语 + +本指南涵盖了 Go 语言和 Gin 框架的核心知识点,从基础语法到高级特性,从并发编程到工程实践。建议按照以下路径学习: + +1. **基础阶段**:掌握 Go 基础语法、数据结构、接口 +2. **进阶阶段**:深入理解并发、Channel、Context +3. **框架阶段**:学习 Gin 路由、中间件、请求处理 +4. **工程阶段**:实践项目结构、测试、Docker 部署 + +多写代码、多读优秀开源项目(如 new-api)是提升的最佳途径! + +--- + +**参考资源**: +- [Go 官方文档](https://golang.org/doc/) +- [Gin 框架文档](https://gin-gonic.com/docs/) +- [Go 语言高级编程](https://chai2010.cn/advanced-go-programming-book/) +- [Effective Go](https://golang.org/doc/effective_go.html) diff --git a/docs/relay-architecture-deep-dive.md b/docs/relay-architecture-deep-dive.md new file mode 100644 index 000000000000..e86ccd744777 --- /dev/null +++ b/docs/relay-architecture-deep-dive.md @@ -0,0 +1,905 @@ +# Relay 架构深度解析 + +## 概述 + +**Relay** 是 new-api 项目的核心组件,负责将客户端的 AI API 请求转发到上游提供商(OpenAI、Claude、Gemini 等 40+ 家)。它实现了统一的 API 网关,提供协议转换、负载均衡、计费、重试等关键能力。 + +``` +┌─────────────┐ ┌─────────────────────────────────────┐ ┌─────────────────┐ +│ 客户端 │────▶│ Relay 层 │────▶│ OpenAI │ +│ (OpenAI SDK)│◄────│ (协议转换 · 负载均衡 · 计费 · 缓存) │◄────│ Claude │ +└─────────────┘ └─────────────────────────────────────┘ │ Gemini │ + │ 阿里云/百度/... │ + └─────────────────┘ +``` + +--- + +## 一、核心架构设计 + +### 1.1 分层架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Controller 层 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ Relay() │ │ RelayTask│ │RelayMidjourney│ ... │ │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │ +└───────┼────────────┼────────────┼────────────────┼─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Relay 核心层 │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────────────────┐ │ +│ │ relay_adaptor│ │ relay_task │ │ common_handler │ │ +│ │ (适配器工厂) │ │ (异步任务) │ │ (通用响应处理) │ │ +│ └──────────────┘ └──────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Channel 适配器层 │ +│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │ +│ │ openai │ │ claude │ │ gemini │ │ ali │ │ aws │ ... │ +│ └────────┘ └────────┘ └────────┘ └────────┘ └────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 核心数据结构 + +#### RelayInfo - 请求上下文载体 + +```go +// relay/common/relay_info.go:85-172 +type RelayInfo struct { + // ========== 用户/Token 信息 ========== + TokenId int + TokenKey string + TokenGroup string + UserId int + UsingGroup string // 当前使用的分组(跨分组重试时会变动) + UserGroup string // 用户所在分组 + TokenUnlimited bool + + // ========== 请求元数据 ========== + StartTime time.Time + FirstResponseTime time.Time + IsStream bool + RelayMode int // 请求类型(聊天/嵌入/图片/音频等) + OriginModelName string // 原始模型名称 + RequestURLPath string + + // ========== 计费相关 ========== + ForcePreConsume bool // 强制预扣费(用于异步任务) + Billing BillingSettler // 计费会话 + BillingSource string // "wallet" | "subscription" + PriceData types.PriceData + + // ========== 渠道信息 ========== + *ChannelMeta // 嵌入渠道元数据 + + // ========== 特定功能 ========== + *ClaudeConvertInfo // Claude 协议转换状态 + *RerankerInfo // Rerank 请求信息 + *ResponsesUsageInfo // Responses API 工具使用统计 + *TaskRelayInfo // 异步任务信息 +} +``` + +**设计要点**: +- `RelayInfo` 贯穿整个请求生命周期,避免使用 context 传递导致的信息丢失 +- 使用嵌入结构体(`*ChannelMeta` 等)实现可选/扩展字段 +- 计费信息独立封装,支持钱包和订阅两种计费模式 + +#### ChannelMeta - 渠道元数据 + +```go +// relay/common/relay_info.go:60-78 +type ChannelMeta struct { + ChannelType int + ChannelId int + ChannelIsMultiKey bool + ChannelMultiKeyIndex int + ChannelBaseUrl string + ApiType int // 映射到适配器类型 + ApiVersion string // Azure/Gemini API 版本 + ApiKey string + Organization string + ChannelCreateTime int64 + ParamOverride map[string]interface{} // 参数覆盖 + HeadersOverride map[string]interface{} // Header 覆盖 + ChannelSetting dto.ChannelSettings + UpstreamModelName string + IsModelMapped bool + SupportStreamOptions bool +} +``` + +--- + +## 二、适配器模式(Adapter Pattern) + +### 2.1 适配器接口定义 + +```go +// relay/channel/adapter.go:15-32 +type Adaptor interface { + // 初始化适配器 + Init(info *relaycommon.RelayInfo) + + // 构建请求 URL + GetRequestURL(info *relaycommon.RelayInfo) (string, error) + + // 设置请求头 + SetupRequestHeader(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) error + + // 请求转换方法群(支持多种输入格式) + ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) + ConvertRerankRequest(c *gin.Context, relayMode int, request dto.RerankRequest) (any, error) + ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.EmbeddingRequest) (any, error) + ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) + ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) + ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) + ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) + ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) + + // 执行请求和处理响应 + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) + + // 元数据 + GetModelList() []string + GetChannelName() string +} +``` + +### 2.2 适配器工厂 + +```go +// relay/relay_adaptor.go:53-125 +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeAli: + return &ali.Adaptor{} + case constant.APITypeAnthropic: + return &claude.Adaptor{} + case constant.APITypeBaidu: + return &baidu.Adaptor{} + case constant.APITypeGemini: + return &gemini.Adaptor{} + case constant.APITypeOpenAI: + return &openai.Adaptor{} + case constant.APITypeAws: + return &aws.Adaptor{} + // ... 40+ 家提供商 + } + return nil +} +``` + +**设计优势**: +1. **解耦**:Controller 无需关心具体提供商实现 +2. **可扩展**:新增提供商只需实现接口并注册到工厂 +3. **一致性**:所有提供商遵循统一的请求/响应处理流程 + +### 2.3 OpenAI 适配器示例 + +```go +// relay/channel/openai/adaptor.go:37-40 +type Adaptor struct { + ChannelType int + ResponseFormat string +} + +// Init 初始化适配器状态 +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType + // 初始化 ThinkingContentInfo(当启用 thinking_to_content 时) + if info.ChannelSetting.ThinkingToContent { + info.ThinkingContentInfo = relaycommon.ThinkingContentInfo{...} + } +} + +// GetRequestURL 处理不同渠道的 URL 构建 +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + switch info.ChannelType { + case constant.ChannelTypeAzure: + // Azure 特殊处理:/openai/deployments/{model}/{task}?api-version={version} + return buildAzureURL(info) + case constant.ChannelTypeCustom: + // 自定义渠道支持 {model} 占位符替换 + url := info.ChannelBaseUrl + url = strings.Replace(url, "{model}", info.UpstreamModelName, -1) + return url, nil + default: + return relaycommon.GetFullRequestURL(info.ChannelBaseUrl, info.RequestURLPath, info.ChannelType), nil + } +} + +// ConvertOpenAIRequest 请求转换与参数适配 +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + // OpenRouter 特殊适配 + if info.ChannelType == constant.ChannelTypeOpenRouter { + // 处理 thinking 后缀、reasoning 参数等 + adaptForOpenRouter(request, info) + } + + // o-series/gpt-5 模型适配 + if strings.HasPrefix(info.UpstreamModelName, "o") || strings.HasPrefix(info.UpstreamModelName, "gpt-5") { + // 转换 MaxTokens → MaxCompletionTokens + adaptOSeriesModels(request, info) + } + + return request, nil +} + +// DoResponse 根据 RelayMode 分发到不同处理器 +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + switch info.RelayMode { + case relayconstant.RelayModeRealtime: + err, usage = OpenaiRealtimeHandler(c, info) + case relayconstant.RelayModeAudioSpeech: + usage = OpenaiTTSHandler(c, resp, info) + case relayconstant.RelayModeRerank: + usage, err = common_handler.RerankHandler(c, info, resp) + default: + if info.IsStream { + usage, err = OaiStreamHandler(c, info, resp) + } else { + usage, err = OpenaiHandler(c, info, resp) + } + } + return +} +``` + +--- + +## 三、请求处理流程 + +### 3.1 同步请求完整流程 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 请求处理流程 │ +└─────────────────────────────────────────────────────────────────────┘ + + ① 接收请求 + │ + ▼ + ② 生成 RelayInfo + │ GenRelayInfo(c, relayFormat, request, ws) + │ ├── 设置基础信息(UserId, TokenId, 模型名等) + │ ├── 设置 RelayMode(根据请求路径) + │ └── 设置格式特定信息(ClaudeConvertInfo/RerankerInfo等) + │ + ▼ + ③ Token 估算与敏感词检查 + │ + ▼ + ④ 价格计算与预扣费 + │ helper.ModelPriceHelper() + │ └── service.PreConsumeBilling() + │ + ▼ + ⑤ 渠道选择与重试循环 + │ for retry <= MaxRetries { + │ channel := getChannel() // 负载均衡选择 + │ adaptor := GetAdaptor(apiType) // 获取适配器 + │ adaptor.Init(info) + │ + │ // 构建请求 + │ url := adaptor.GetRequestURL(info) + │ header := adaptor.SetupRequestHeader(c, &req.Header, info) + │ body := adaptor.ConvertOpenAIRequest(c, info, request) + │ + │ // 发送与处理 + │ resp, _ := adaptor.DoRequest(c, info, body) + │ usage, err := adaptor.DoResponse(c, resp, info) + │ + │ if err == nil { break } + │ if !shouldRetry(err) { break } + │ } + │ + ▼ + ⑥ 计费结算 + │ service.SettleBilling(ctx, relayInfo, actualQuota) + │ + ▼ + ⑦ 返回响应 +``` + +### 3.2 Controller 层核心代码 + +```go +// controller/relay.go:67-242 +func Relay(c *gin.Context, relayFormat types.RelayFormat) { + // 1. 解析并验证请求 + request, err := helper.GetAndValidateRequest(c, relayFormat) + + // 2. 生成 RelayInfo + relayInfo, err := relaycommon.GenRelayInfo(c, relayFormat, request, ws) + + // 3. Token 估算 + tokens, err := service.EstimateRequestToken(c, meta, relayInfo) + relayInfo.SetEstimatePromptTokens(tokens) + + // 4. 价格计算与预扣费 + priceData, err := helper.ModelPriceHelper(c, relayInfo, tokens, meta) + if !priceData.FreeModel { + service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo) + } + + // 5. 重试循环 + retryParam := &service.RetryParam{...} + for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { + channel, channelErr := getChannel(c, relayInfo, retryParam) + addUsedChannel(c, channel.Id) + + // 根据格式分发到不同处理器 + switch relayFormat { + case types.RelayFormatOpenAIRealtime: + newAPIError = relay.WssHelper(c, relayInfo) + case types.RelayFormatClaude: + newAPIError = relay.ClaudeHelper(c, relayInfo) + case types.RelayFormatGemini: + newAPIError = geminiRelayHandler(c, relayInfo) + default: + newAPIError = relayHandler(c, relayInfo) // 通用处理 + } + + if newAPIError == nil { return } // 成功 + if !shouldRetry(c, newAPIError, remainingRetries) { break } + } +} + +// 根据 RelayMode 选择具体处理器 +func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + switch info.RelayMode { + case relayconstant.RelayModeImagesGenerations: + return relay.ImageHelper(c, info) + case relayconstant.RelayModeAudioSpeech: + return relay.AudioHelper(c, info) + case relayconstant.RelayModeRerank: + return relay.RerankHelper(c, info) + case relayconstant.RelayModeEmbeddings: + return relay.EmbeddingHelper(c, info) + case relayconstant.RelayModeResponses: + return relay.ResponsesHelper(c, info) + default: + return relay.TextHelper(c, info) // 默认文本聊天 + } +} +``` + +--- + +## 四、异步任务(Task)设计 + +### 4.1 Task 与同步请求的区别 + +| 特性 | 同步请求 | 异步任务 | +|------|----------|----------| +| 响应时间 | 即时(秒级) | 延迟(分钟级) | +| 典型场景 | 聊天、嵌入 | 视频生成、音乐生成 | +| 计费模式 | 按 Token | 按次/按参数(时长、分辨率) | +| 状态管理 | 无 | 需持久化任务状态 | +| 结果获取 | 直接返回 | 需要轮询查询 | + +### 4.2 TaskAdaptor 接口 + +```go +// relay/channel/adapter.go:34-79 +type TaskAdaptor interface { + Init(info *relaycommon.RelayInfo) + + // 验证与计费估算 + ValidateRequestAndSetAction(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError + EstimateBilling(c *gin.Context, info *relaycommon.RelayInfo) map[string]float64 // 返回 OtherRatios + AdjustBillingOnSubmit(info *relaycommon.RelayInfo, taskData []byte) map[string]float64 + AdjustBillingOnComplete(task *model.Task, taskResult *relaycommon.TaskInfo) int + + // 请求构建 + BuildRequestURL(info *relaycommon.RelayInfo) (string, error) + BuildRequestHeader(c *gin.Context, req *http.Request, info *relaycommon.RelayInfo) error + BuildRequestBody(c *gin.Context, info *relaycommon.RelayInfo) (io.Reader, error) + + // 请求执行 + DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (*http.Response, error) + DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (taskID string, taskData []byte, err *dto.TaskError) + + // 轮询相关 + FetchTask(baseUrl, key string, body map[string]any, proxy string) (*http.Response, error) + ParseTaskResult(respBody []byte) (*relaycommon.TaskInfo, error) + + GetModelList() []string + GetChannelName() string +} +``` + +### 4.3 任务提交流程 + +```go +// relay/relay_task.go:144-258 +func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitResult, *dto.TaskError) { + info.InitChannelMeta(c) + + // 1. 确定 platform 并创建适配器 + platform := GetTaskPlatform(c) + adaptor := GetTaskAdaptor(platform) + adaptor.Init(info) + + // 2. 验证请求 + if taskErr := adaptor.ValidateRequestAndSetAction(c, info); taskErr != nil { + return nil, taskErr + } + + // 3. 应用模型映射 + helper.ModelMappedHelper(c, info, nil) + + // 4. 预生成公开 Task ID + if info.PublicTaskID == "" { + info.PublicTaskID = model.GenerateTaskID() + } + + // 5. 基础价格计算 + priceData, _ := helper.ModelPriceHelperPerCall(c, info) + info.PriceData = priceData + + // 6. 计费估算(获取 OtherRatios:时长、分辨率等) + if estimatedRatios := adaptor.EstimateBilling(c, info); len(estimatedRatios) > 0 { + for k, v := range estimatedRatios { + info.PriceData.AddOtherRatio(k, v) + } + } + + // 7. 应用 OtherRatios 计算最终额度 + for _, ra := range info.PriceData.OtherRatios { + if ra != 1.0 { + info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) + } + } + + // 8. 预扣费(仅首次) + if info.Billing == nil && !info.PriceData.FreeModel { + info.ForcePreConsume = true + service.PreConsumeBilling(c, info.PriceData.Quota, info) + } + + // 9. 构建并发送请求 + requestBody, _ := adaptor.BuildRequestBody(c, info) + resp, _ := adaptor.DoRequest(c, info, requestBody) + + // 10. 提交后计费调整 + upstreamTaskID, taskData, taskErr := adaptor.DoResponse(c, resp, info) + finalQuota := info.PriceData.Quota + if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { + finalQuota = recalcQuotaFromRatios(info, adjustedRatios) + } + + return &TaskSubmitResult{ + UpstreamTaskID: upstreamTaskID, + TaskData: taskData, + Platform: platform, + Quota: finalQuota, + }, nil +} +``` + +### 4.4 计费模型:OtherRatios + +异步任务采用多维度计费模型: + +``` +最终额度 = 基础价格 × 时长比例 × 分辨率比例 × 分组倍率 + +示例(视频生成): +- 基础价格: 1000 quota +- 时长比例: 5秒 → 5.0 +- 分辨率比例: 1080p → 1.666 +- 分组倍率: 1.0 + +最终额度 = 1000 × 5.0 × 1.666 × 1.0 = 8330 quota +``` + +```go +// relay/relay_task.go:262-279 +func recalcQuotaFromRatios(info *relaycommon.RelayInfo, ratios map[string]float64) int { + // 1. 从当前额度恢复基础额度 + baseQuota := info.PriceData.Quota + for _, ra := range info.PriceData.OtherRatios { + if ra != 1.0 && ra > 0 { + baseQuota = int(float64(baseQuota) / ra) + } + } + + // 2. 应用新的 ratios + result := float64(baseQuota) + for _, ra := range ratios { + if ra != 1.0 { + result *= ra + } + } + return int(result) +} +``` + +--- + +## 五、重试机制 + +### 5.1 重试决策逻辑 + +```go +// controller/relay.go:318-348 +func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) bool { + if openaiErr == nil { + return false + } + // 渠道亲和性失败后不重试 + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + return false + } + // 渠道错误可重试(连接失败等) + if types.IsChannelError(openaiErr) { + return true + } + // 明确标记跳过重试的错误 + if types.IsSkipRetryError(openaiErr) { + return false + } + // 耗尽重试次数 + if retryTimes <= 0 { + return false + } + // 指定渠道时不重试 + if _, ok := c.Get("specific_channel_id"); ok { + return false + } + // 2xx 成功状态不重试 + code := openaiErr.StatusCode + if code >= 200 && code < 300 { + return false + } + // 根据配置判断是否重试 + return operation_setting.ShouldRetryByStatusCode(code) +} +``` + +### 5.2 状态码重试策略 + +| 状态码范围 | 默认行为 | 说明 | +|-----------|---------|------| +| 2xx | 不重试 | 成功响应 | +| 429 | 重试 | 限流,可切换渠道重试 | +| 5xx | 重试 | 服务端错误 | +| 400 | 不重试 | 客户端错误,重试无效 | +| 408 | 不重试 | 超时(Azure 特殊处理)| +| <100 或 >599 | 重试 | 非标准 HTTP 状态 | + +--- + +## 六、计费系统 + +### 6.1 计费会话模式 + +```go +// service/billing.go:17-78 + +// PreConsumeBilling 创建计费会话并执行预扣费 +func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError { + session, apiErr := NewBillingSession(c, relayInfo, preConsumedQuota) + if apiErr != nil { + return apiErr + } + relayInfo.Billing = session // 会话绑定到 RelayInfo + return nil +} + +// SettleBilling 结算(支持多退少补) +func SettleBilling(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, actualQuota int) error { + if relayInfo.Billing != nil { + preConsumed := relayInfo.Billing.GetPreConsumedQuota() + delta := actualQuota - preConsumed + + if delta > 0 { + // 实际消耗 > 预扣费,补扣差额 + logger.LogInfo(ctx, fmt.Sprintf("预扣费后补扣费:%s", logger.FormatQuota(delta))) + } else if delta < 0 { + // 实际消耗 < 预扣费,返还差额 + logger.LogInfo(ctx, fmt.Sprintf("预扣费后返还扣费:%s", logger.FormatQuota(-delta))) + } + + return relayInfo.Billing.Settle(actualQuota) + } + // 回退到旧路径 + return PostConsumeQuota(relayInfo, quotaDelta, relayInfo.FinalPreConsumedQuota, true) +} +``` + +### 6.2 计费流程 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 计费流程 │ +└─────────────────────────────────────────────────────────────┘ + + ① 价格计算 + │ helper.ModelPriceHelper() + │ ├── 获取模型基础价格 + │ ├── 应用模型倍率 (ModelRatio) + │ ├── 应用分组倍率 (GroupRatio) + │ └── 计算预扣额度 + │ + ▼ + ② 预扣费 + │ service.PreConsumeBilling() + │ ├── 检查余额/订阅额度 + │ ├── 扣除预扣额度 + │ └── 创建 BillingSession + │ + ▼ + ③ 请求执行(可能重试) + │ + ▼ + ④ 结算 + │ service.SettleBilling() + │ ├── 计算实际消耗 + │ ├── 多退少补 + │ └── 记录日志 + │ + ▼ + ⑤ 失败回滚 + │ defer: Billing.Refund() + └── 返还预扣额度 +``` + +--- + +## 七、多协议支持 + +### 7.1 支持的请求格式 + +```go +// types/relay_format.go +type RelayFormat string + +const ( + RelayFormatOpenAI RelayFormat = "openai" + RelayFormatOpenAIAudio RelayFormat = "openai_audio" + RelayFormatOpenAIImage RelayFormat = "openai_image" + RelayFormatOpenAIRealtime RelayFormat = "openai_realtime" + RelayFormatOpenAIResponses RelayFormat = "openai_responses" + RelayFormatOpenAIResponsesCompaction RelayFormat = "openai_responses_compaction" + RelayFormatClaude RelayFormat = "claude" + RelayFormatGemini RelayFormat = "gemini" + RelayFormatEmbedding RelayFormat = "embedding" + RelayFormatRerank RelayFormat = "rerank" + RelayFormatTask RelayFormat = "task" + RelayFormatMjProxy RelayFormat = "mj_proxy" +) +``` + +### 7.2 请求转换链 + +```go +// relay/common/relay_info.go:575-617 + +// 记录请求格式转换历史 +func (info *RelayInfo) AppendRequestConversion(format types.RelayFormat) { + if len(info.RequestConversionChain) == 0 { + info.RequestConversionChain = []types.RelayFormat{format} + return + } + last := info.RequestConversionChain[len(info.RequestConversionChain)-1] + if last == format { + return + } + info.RequestConversionChain = append(info.RequestConversionChain, format) +} + +// 获取最终请求格式 +func (info *RelayInfo) GetFinalRequestRelayFormat() types.RelayFormat { + if info.FinalRequestRelayFormat != "" { + return info.FinalRequestRelayFormat + } + if n := len(info.RequestConversionChain); n > 0 { + return info.RequestConversionChain[n-1] + } + return info.RelayFormat +} +``` + +**转换示例**: +- Claude SDK → OpenAI API: `["claude", "openai"]` +- Gemini SDK → OpenAI API: `["gemini", "openai"]` +- OpenAI → Claude API: `["openai", "claude"]` + +--- + +## 八、关键设计决策 + +### 8.1 为什么使用 RelayInfo 而不是 Context? + +| 方案 | 优点 | 缺点 | +|------|------|------| +| Context | 标准做法,跨层透传 | 类型不安全,异步处理时信息易丢失 | +| **RelayInfo(当前)** | 类型安全,显式依赖,异步友好 | 参数显式传递,略显冗长 | + +**关键考量**: +- 异步任务(Task)需要持久化任务状态,Context 无法序列化 +- 重试时需要保持完整的请求上下文 +- 计费信息需要跨多个函数调用保持一致 + +### 8.2 适配器 vs. 函数式编程 + +```go +// 方案对比 + +// 方案A:函数式(每个提供商一组函数) +func OpenAIConvertRequest(...) (any, error) +func OpenAIDoRequest(...) (any, error) +func OpenAIDoResponse(...) (any, error) + +// 方案B:适配器模式(当前采用) +type Adaptor interface { ... } +type OpenAIAdaptor struct{} +func (a *OpenAIAdaptor) ConvertRequest(...) (any, error) + +// 选择方案B的原因: +// 1. 状态管理:适配器可持有状态(如 ChannelType、ResponseFormat) +// 2. 接口约束:编译时检查是否实现所有方法 +// 3. 工厂模式:通过 apiType 直接获取对应适配器 +``` + +### 8.3 流式响应处理 + +```go +// 流式处理采用逐行扫描 + SSE 格式输出 +func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) { + // 1. 设置 SSE 头 + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + + // 2. 逐行读取上游响应 + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + + // 3. 转换响应格式(如需要) + transformed := transformStreamLine(line, info) + + // 4. 发送给客户端 + fmt.Fprintf(c.Writer, "data: %s\n\n", transformed) + c.Writer.Flush() + + // 5. 统计 Usage + usage.AddStreamChunk(line) + } + + // 6. 发送结束标记 + fmt.Fprintf(c.Writer, "data: [DONE]\n\n") + return usage, nil +} +``` + +--- + +## 九、扩展指南 + +### 9.1 添加新的上游提供商 + +1. **创建适配器文件**:`relay/channel/{provider}/adaptor.go` + +```go +package myprovider + +type Adaptor struct { + ChannelType int +} + +func (a *Adaptor) Init(info *relaycommon.RelayInfo) { + a.ChannelType = info.ChannelType +} + +func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { + return fmt.Sprintf("%s/v1/chat/completions", info.ChannelBaseUrl), nil +} + +func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeneralOpenAIRequest) (any, error) { + // 如有需要,转换请求格式 + return request, nil +} + +func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) { + // 处理响应,返回 usage 信息 + return +} +``` + +2. **注册到工厂**:`relay/relay_adaptor.go` + +```go +func GetAdaptor(apiType int) channel.Adaptor { + switch apiType { + case constant.APITypeMyProvider: + return &myprovider.Adaptor{} + } +} +``` + +3. **定义常量**:`constant/channel_type.go` + +```go +const ( + ChannelTypeMyProvider = 45 +) +``` + +### 9.2 添加新的 RelayMode + +1. **定义模式常量**:`relay/constant/relay_mode.go` + +```go +const ( + RelayModeMyFeature = iota + 1 +) +``` + +2. **添加路径映射**:`relay/constant/path_mapping.go` + +```go +func Path2RelayMode(path string) int { + switch { + case strings.HasSuffix(path, "/my-feature"): + return RelayModeMyFeature + } +} +``` + +3. **实现处理器**:`relay/my_feature.go` + +```go +func MyFeatureHelper(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + // 获取适配器 + adaptor := GetAdaptor(info.ApiType) + adaptor.Init(info) + + // 构建请求 + url, _ := adaptor.GetRequestURL(info) + // ... 发送请求 + + // 处理响应 + usage, err := adaptor.DoResponse(c, resp, info) + + // 结算 + service.SettleBilling(c, info, calculateQuota(usage)) + return err +} +``` + +4. **注册到 Controller**:`controller/relay.go` + +```go +func relayHandler(c *gin.Context, info *relaycommon.RelayInfo) *types.NewAPIError { + switch info.RelayMode { + case relayconstant.RelayModeMyFeature: + return relay.MyFeatureHelper(c, info) + } +} +``` + +--- + +## 十、总结 + +Relay 架构的核心设计原则: + +1. **统一抽象**:通过 `Adaptor` 接口屏蔽 40+ 家提供商的差异 +2. **状态集中**:`RelayInfo` 承载完整请求上下文,支持同步/异步/重试场景 +3. **可观测性**:详细的日志记录、错误分类、渠道健康检查 +4. **计费精确**:预扣费 + 结算模式,支持多维度计费(Token/次数/参数) +5. **高可用性**:智能重试、负载均衡、渠道自动禁用 + +这种设计使得添加新的 AI 提供商或功能只需实现少量接口,而不会破坏现有代码,实现了良好的可扩展性和可维护性。 diff --git a/middleware/logger.go b/middleware/logger.go index 151008d9f23a..f2a437068d01 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -18,6 +18,11 @@ func RouteTag(tag string) gin.HandlerFunc { func SetUpLogger(server *gin.Engine) { server.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + // 跳过负载均衡器健康检查日志(HEAD / 请求) + if param.Method == "HEAD" && param.Path == "/" { + return "" + } + var requestID string if param.Keys != nil { requestID, _ = param.Keys[common.RequestIdKey].(string) diff --git a/model/main.go b/model/main.go index f37cb667cd43..4c9421a73e7b 100644 --- a/model/main.go +++ b/model/main.go @@ -280,6 +280,7 @@ func migrateDB() error { &SubscriptionPreConsumeRecord{}, &CustomOAuthProvider{}, &UserOAuthBinding{}, + &Skill{}, ) if err != nil { return err diff --git a/model/skill.go b/model/skill.go new file mode 100644 index 000000000000..a476332fee10 --- /dev/null +++ b/model/skill.go @@ -0,0 +1,152 @@ +package model + +import ( + "time" + + "github.com/QuantumNous/new-api/common" + "gorm.io/gorm" +) + +// SkillCoreFeature 技能核心特性 +type SkillCoreFeature struct { + Title string `json:"title"` + Description string `json:"description"` +} + +// Skill 技能元数据模型 +type Skill struct { + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Slug string `json:"slug" gorm:"uniqueIndex;type:varchar(128);not null"` + Title string `json:"title" gorm:"type:varchar(255);not null"` + Description string `json:"description" gorm:"type:text"` + AvatarUrl *string `json:"avatar_url" gorm:"type:varchar(512)"` + CategoryId int `json:"category_id" gorm:"type:int;default:0"` + CategoryAvatarUrl string `json:"category_avatar_url" gorm:"type:varchar(512);default:''"` + Version string `json:"version" gorm:"type:varchar(32);default:'1.0.0'"` + ActualUrl string `json:"actual_url" gorm:"type:varchar(512)"` + Tag string `json:"tag" gorm:"type:varchar(128);index"` + Downloads int `json:"downloads" gorm:"type:int;default:0"` + Stars int `json:"stars" gorm:"type:int;default:0"` + CoreFeatures []SkillCoreFeature `json:"core_features" gorm:"type:text;serializer:json"` + UseCases []string `json:"use_cases" gorm:"type:text;serializer:json"` + IsActive bool `json:"is_active" gorm:"default:true"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` +} + +// TableName 指定表名 +func (Skill) TableName() string { + return "skills" +} + +// CreateSkill 创建技能 +func CreateSkill(skill *Skill) error { + return DB.Create(skill).Error +} + +// UpdateSkill 更新技能 +func UpdateSkill(skill *Skill) error { + return DB.Save(skill).Error +} + +// DeleteSkill 删除技能(软删除) +func DeleteSkill(id int) error { + return DB.Delete(&Skill{}, id).Error +} + +// GetSkillById 根据 ID 获取技能 +func GetSkillById(id int) (*Skill, error) { + var skill Skill + err := DB.First(&skill, id).Error + if err != nil { + return nil, err + } + return &skill, nil +} + +// GetSkillBySlug 根据 Slug 获取技能 +func GetSkillBySlug(slug string) (*Skill, error) { + var skill Skill + err := DB.Where("slug = ?", slug).First(&skill).Error + if err != nil { + return nil, err + } + return &skill, nil +} + +// GetAllSkills 获取所有技能(支持分页和 tag 过滤) +func GetAllSkills(startIdx, pageSize int, tag string) ([]*Skill, error) { + var skills []*Skill + query := DB.Model(&Skill{}).Where("is_active = ?", true) + + if tag != "" { + query = query.Where("tag = ?", tag) + } + + err := query.Order("id DESC").Offset(startIdx).Limit(pageSize).Find(&skills).Error + return skills, err +} + +// CountSkills 统计技能数量(支持 tag 过滤) +func CountSkills(tag string) (int64, error) { + var total int64 + query := DB.Model(&Skill{}).Where("is_active = ?", true) + + if tag != "" { + query = query.Where("tag = ?", tag) + } + + err := query.Count(&total).Error + return total, err +} + +// IncrementDownloads 增加下载次数 +func IncrementDownloads(id int) error { + return DB.Model(&Skill{}).Where("id = ?", id). + UpdateColumn("downloads", DB.Raw("downloads + 1")).Error +} + +// IncrementStars 增加星标数 +func IncrementStars(id int, delta int) error { + return DB.Model(&Skill{}).Where("id = ?", id). + UpdateColumn("stars", DB.Raw("stars + ?", delta)).Error +} + +// SearchSkills 搜索技能 +func SearchSkills(keyword string, startIdx, pageSize int) ([]*Skill, int64, error) { + var skills []*Skill + var total int64 + + query := DB.Model(&Skill{}).Where("is_active = ?", true) + searchPattern := "%" + keyword + "%" + + query = query.Where("title LIKE ? OR description LIKE ? OR tag LIKE ?", + searchPattern, searchPattern, searchPattern) + + err := query.Count(&total).Error + if err != nil { + return nil, 0, err + } + + err = query.Order("id DESC").Offset(startIdx).Limit(pageSize).Find(&skills).Error + return skills, total, err +} + +// GetAllSkillTags 获取所有标签(去重) +func GetAllSkillTags() ([]string, error) { + var tags []string + err := DB.Model(&Skill{}). + Where("is_active = ? AND tag != ''", true). + Distinct("tag"). + Pluck("tag", &tags).Error + return tags, err +} + +// InitSkillTable 初始化技能表(自动迁移) +func InitSkillTable() { + err := DB.AutoMigrate(&Skill{}) + if err != nil { + common.SysLog("Failed to migrate skill table: " + err.Error()) + } +} diff --git a/model/topup.go b/model/topup.go index d8c92bfe6517..53c3e69439d3 100644 --- a/model/topup.go +++ b/model/topup.go @@ -12,15 +12,15 @@ import ( ) type TopUp struct { - Id int `json:"id"` - UserId int `json:"user_id" gorm:"index"` - Amount int64 `json:"amount"` - Money float64 `json:"money"` - TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` - PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` - CreateTime int64 `json:"create_time"` - CompleteTime int64 `json:"complete_time"` - Status string `json:"status"` + Id int `json:"id"` + UserId int `json:"user_id" gorm:"index"` + Amount int64 `json:"amount"` + Money float64 `json:"money"` + TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` + PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"` + CreateTime int64 `json:"create_time"` + CompleteTime int64 `json:"complete_time"` + Status string `json:"status"` } func (topUp *TopUp) Insert() error { @@ -55,6 +55,7 @@ func GetTopUpByTradeNo(tradeNo string) *TopUp { return topUp } +// For Stripe Only func Recharge(referenceId string, customerId string) (err error) { if referenceId == "" { return errors.New("未提供支付单号") @@ -73,7 +74,10 @@ func Recharge(referenceId string, customerId string) (err error) { if err != nil { return errors.New("充值订单不存在") } - + // 验证订单必须是 Stripe 支付方式 + if topUp.PaymentMethod != "stripe" { + return errors.New("支付方式异常") + } if topUp.Status != common.TopUpStatusPending { return errors.New("充值订单状态错误") } diff --git a/router/main.go b/router/main.go index ac9506fe45c6..d7a56792beec 100644 --- a/router/main.go +++ b/router/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "strings" + "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/middleware" @@ -14,10 +15,19 @@ import ( ) func SetRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) { + // 健康检查端点(供负载均衡器使用) + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().Unix(), + }) + }) + SetApiRouter(router) SetDashboardRouter(router) SetRelayRouter(router) SetVideoRouter(router) + SetSkillRouter(router) frontendBaseUrl := os.Getenv("FRONTEND_BASE_URL") if common.IsMasterNode && frontendBaseUrl != "" { frontendBaseUrl = "" diff --git a/router/skill-router.go b/router/skill-router.go new file mode 100644 index 000000000000..3b7bc80b87f8 --- /dev/null +++ b/router/skill-router.go @@ -0,0 +1,44 @@ +package router + +import ( + "github.com/QuantumNous/new-api/controller" + "github.com/QuantumNous/new-api/middleware" + + "github.com/gin-gonic/gin" +) + +// SetSkillRouter 设置技能相关路由 +func SetSkillRouter(router *gin.Engine) { + // 公开 API 路由(无需认证) + skillPublicRoute := router.Group("/api/skill") + skillPublicRoute.Use(middleware.RouteTag("api")) + { + // 获取技能列表(支持 tag 过滤和分页) + skillPublicRoute.GET("/", controller.GetAllSkills) + // 搜索技能 + skillPublicRoute.GET("/search", controller.SearchSkills) + // 获取所有标签 + skillPublicRoute.GET("/tags", controller.GetAllSkillTags) + // 获取单个技能详情 + skillPublicRoute.GET("/:id", controller.GetSkill) + // 下载技能 + skillPublicRoute.GET("/download/:id", controller.DownloadSkill) + } + + // 管理 API 路由(需要管理员权限) + skillAdminRoute := router.Group("/api/skill") + skillAdminRoute.Use(middleware.RouteTag("api")) + skillAdminRoute.Use(middleware.AdminAuth()) + { + // 创建技能 + skillAdminRoute.POST("/", controller.AddSkill) + // 更新技能 + skillAdminRoute.PUT("/:id", controller.UpdateSkill) + // 删除技能 + skillAdminRoute.DELETE("/:id", controller.DeleteSkill) + } + + // 技能文件下载服务(静态文件) + // 访问路径: /skills/downloads/{filename} + router.Static("/skills/downloads", "./skills/downloads") +} diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000000..c3b7e9481696 --- /dev/null +++ b/skills/README.md @@ -0,0 +1,240 @@ +# Skills 技能管理模块 + +## 概述 + +Skills 模块提供了技能元数据的管理功能,包括 CRUD 操作、搜索、标签过滤和文件下载服务。 + +## 数据结构 + +### Skill 技能元数据 + +```json +{ + "id": 101, + "slug": "skill-creator", + "title": "技能开发助手", + "description": "创建、编辑、改进或审核智能体技能。适用于从零开始创建新技能。", + "avatar_url": null, + "category_id": 5, + "category_avatar_url": "", + "version": "1.0.0", + "actual_url": "https://omnirouter.xxx.com/skills/downloads/skill-creator.zip", + "tag": "效率工具", + "downloads": 0, + "stars": 313611, + "core_features": [ + { + "title": "技能架构设计", + "description": "提供清晰的结构与组织原则,让开发更有条理" + } + ], + "use_cases": [ + "技能设计:告诉我你想要完成的任务,我会帮你生成技能和脚本" + ], + "is_active": true, + "created_at": "2026-03-15T15:03:20Z", + "updated_at": "2026-03-15T15:03:20Z" +} +``` + +## API 接口 + +### 公开接口(无需认证) + +#### 获取技能列表 +``` +GET /api/skill/ +``` + +**查询参数:** +- `p`: 页码(默认 1) +- `page_size`: 每页数量(默认 10) +- `tag`: 标签过滤(可选) + +**响应示例:** +```json +{ + "success": true, + "message": "", + "data": { + "page": 1, + "page_size": 10, + "total": 100, + "items": [...] + } +} +``` + +#### 搜索技能 +``` +GET /api/skill/search?keyword=xxx +``` + +**查询参数:** +- `keyword`: 搜索关键词(在标题、描述、标签中搜索) +- `p`: 页码 +- `page_size`: 每页数量 + +#### 获取所有标签 +``` +GET /api/skill/tags +``` + +**响应示例:** +```json +{ + "success": true, + "message": "", + "data": { + "tags": ["效率工具", "开发工具", "数据分析"] + } +} +``` + +#### 获取技能详情 +``` +GET /api/skill/:id +``` + +#### 下载技能 +``` +GET /api/skill/download/:id +``` + +**响应示例:** +```json +{ + "success": true, + "download_url": "https://xxx/skills/downloads/skill-creator.zip", + "skill": {...} +} +``` + +### 管理接口(需要管理员权限) + +#### 创建技能 +``` +POST /api/skill/ +``` + +**请求体:** +```json +{ + "slug": "skill-creator", + "title": "技能开发助手", + "description": "创建、编辑、改进或审核智能体技能", + "tag": "效率工具", + "actual_url": "https://xxx/skills/downloads/skill-creator.zip", + "core_features": [ + { + "title": "技能架构设计", + "description": "提供清晰的结构与组织原则" + } + ], + "use_cases": ["技能设计:..."], + "is_active": true +} +``` + +#### 更新技能 +``` +PUT /api/skill/:id +``` + +#### 删除技能 +``` +DELETE /api/skill/:id +``` + +## 文件下载服务 + +技能文件存放在 `skills/downloads/` 目录下,通过以下路径访问: + +``` +GET /skills/downloads/{filename} +``` + +例如,`skills/downloads/skill-creator.zip` 文件可通过以下 URL 访问: + +``` +http://localhost:3000/skills/downloads/skill-creator.zip +``` + +## 数据库表结构 + +```sql +CREATE TABLE skills ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug VARCHAR(128) UNIQUE NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + avatar_url VARCHAR(512), + category_id INTEGER DEFAULT 0, + category_avatar_url VARCHAR(512) DEFAULT '', + version VARCHAR(32) DEFAULT '1.0.0', + actual_url VARCHAR(512), + tag VARCHAR(128), + downloads INTEGER DEFAULT 0, + stars INTEGER DEFAULT 0, + core_features TEXT, -- JSON 格式 + use_cases TEXT, -- JSON 格式 + is_active BOOLEAN DEFAULT 1, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME +); + +CREATE INDEX idx_skills_tag ON skills(tag); +CREATE INDEX idx_skills_deleted_at ON skills(deleted_at); +``` + +## 使用示例 + +### 创建技能 + +```bash +curl -X POST http://localhost:3000/api/skill/ \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "slug": "skill-creator", + "title": "技能开发助手", + "description": "创建、编辑、改进或审核智能体技能", + "tag": "效率工具", + "actual_url": "http://localhost:3000/skills/downloads/skill-creator.zip", + "core_features": [ + { + "title": "技能架构设计", + "description": "提供清晰的结构与组织原则" + } + ], + "use_cases": ["技能设计:告诉我你想要完成的任务"], + "is_active": true + }' +``` + +### 查询技能列表 + +```bash +# 获取所有技能 +curl http://localhost:3000/api/skill/ + +# 按标签过滤 +curl http://localhost:3000/api/skill/?tag=效率工具 + +# 分页查询 +curl http://localhost:3000/api/skill/?p=2&page_size=20 +``` + +### 搜索技能 + +```bash +curl "http://localhost:3000/api/skill/search?keyword=开发" +``` + +## 注意事项 + +1. **文件上传**:当前版本不包含文件上传功能,需要手动将 `.zip` 文件放到 `skills/downloads/` 目录 +2. **权限控制**:创建、更新、删除操作需要管理员权限 +3. **软删除**:删除操作为软删除,数据仍保留在数据库中 +4. **下载计数**:每次下载会自动增加 `downloads` 字段的计数 diff --git a/skills/downloads/.gitkeep b/skills/downloads/.gitkeep new file mode 100644 index 000000000000..7f77d8975358 --- /dev/null +++ b/skills/downloads/.gitkeep @@ -0,0 +1,2 @@ +# This file ensures the directory is tracked by Git +# Place skill .zip files in this directory for download service diff --git a/test_api_refactored.py b/test_api_refactored.py new file mode 100644 index 000000000000..10125c5af7fe --- /dev/null +++ b/test_api_refactored.py @@ -0,0 +1,187 @@ +import requests +import json +import base64 +import webbrowser +import tempfile +import os + +# --- Global Configuration --- +# You can switch this to "https://airouter.my" to test remote endpoints. +BASE_URL = "https://omnirouter.aiyuanxi.com" +API_KEY = "sk-nae222lfZUlmDELrRuMeE7eAjWA8xtpsREFRY9nh4bPhpMWh" # aliyun iclaw +API_KEY = "sk-vf21rls6PsDJ7c5mudvMhnUKwU8hMz5I6pOTfHOOP8vSrSZF" # user key: wangruntao +API_KEY = "sk-lrEzozeDfQZrPOncmooAG2OITJEq2FAvu7YC1DbpodTBDGPJ" # user key: hanxingkai +# STEP3_KEY = "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" + +def _execute_request(model, url, body, headers): + """Helper function to execute a single API request and return the response object.""" + print(f"--- Testing model: {model} ---") + try: + response = requests.post(url, json=body, headers=headers, timeout=60) + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + print(f"ERROR: Request failed for model {model}: {e}") + return None + finally: + print("-" * 50) + +# --- Generic Test Functions --- + +def test_chat_model(model): + """Tests any OpenAI-compatible chat model.""" + url = f"{BASE_URL}/v1/chat/completions" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"} + body = {"model": model, "messages": [{"role": "user", "content": "hello"}], "max_tokens": 100} + response = _execute_request(model, url, body, headers) + if response: + print(response.text) + return True + return False + +def test_image_generation_model(model): + """Tests an image generation model and displays the output.""" + url = f"{BASE_URL}/v1/images/generations/" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"} + body = {"model": model, "prompt": "A beautiful sunset over the mountains", "n": 1} + response = _execute_request(model, url, body, headers) + if not response: + return False + + try: + data = response.json() + if not data.get("data"): + print("No 'data' field in response:", response.text) + return False + + for i, item in enumerate(data["data"]): + if item.get("url"): + print(f"Opening image URL for model {model}: {item['url']}") + webbrowser.open(item["url"]) + elif item.get("b64_json"): + print(f"Displaying base64 image for model {model}...") + img_data = base64.b64decode(item["b64_json"]) + with tempfile.NamedTemporaryFile(delete=False, mode='w', suffix='.html') as f: + f.write(f''' + + Image for {model} + + + ''') + webbrowser.open(f"file://{os.path.realpath(f.name)}") + return True + except (json.JSONDecodeError, KeyError) as e: + print(f"Error parsing response for {model}: {e}") + print("Raw response:", response.text) + return False + +def test_anthropic_model(model): + """Tests an Anthropic-compatible model.""" + url = f"{BASE_URL}/v1/messages" + headers = { + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + "Authorization": f"Bearer {API_KEY}", + } + body = {"model": model, "messages": [{"role": "user", "content": "hello"}], "max_tokens": 100} + response = _execute_request(model, url, body, headers) + if response: + print(response.text) + return True + return False + +def test_gemini_text_model(model): + """Tests a Gemini-compatible text model.""" + url = f"{BASE_URL}/v1beta/models/{model}:generateContent" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"} + body = { + "model": model, + "contents": [{"role": "user", "parts": [{"text": "你好"}]}] + } + response = _execute_request(model, url, body, headers) + if response: + print(response.text) + return True + return False + +def test_gemini_image_model(model): + """Tests a Gemini-compatible image model.""" + url = f"{BASE_URL}/v1beta/models/{model}:generateContent" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"} + body = {"contents": [{"parts": [{"text": "hi"}]}]} + response = _execute_request(model, url, body, headers) + if response: + # Image models for Gemini might not return a URL but other data. + # For now, we just check for a successful response. + print(model, response.text) + return True + return False + + +TEST_SUITE = { + # Anthropic Models + # "claude-opus-4-6": test_anthropic_model, + # "claude-sonnet-4-6": test_anthropic_model, + # "claude-haiku-4-5-20251001": test_anthropic_model, + # "claude-opus-4-5-20251101": test_anthropic_model, + # "claude-sonnet-4-5-20250929": test_anthropic_model, + + # Gemini Models + # "gemini-3.1-flash-lite-preview": test_gemini_text_model, + # "gemini-3.1-pro-preview": test_gemini_text_model, + # "gemini-3-flash-preview": test_gemini_text_model, + # "gemini-2.5-pro": test_gemini_text_model, + # "gemini-2.5-flash": test_gemini_text_model, + ### "gemini-2.5-flashlite": test_gemini_text_model, # (Not Test) + # Gemini multimodal models + # "gemini-3.1-flash-image-preview": test_gemini_image_model, + # "gemini-3-pro-image-preview": test_gemini_image_model, + # "gemini-2.5-flash-image": test_gemini_image_model, + + # OpenAI Text Models + # "gpt-5.4": test_chat_model, + # "gpt-5.3-codex": test_chat_model, + # "gpt-5.2": test_chat_model, + # "gpt-5.1": test_chat_model, + # "gpt-5": test_chat_model, + # "gpt-5-mini": test_chat_model, + ### "veo-3.1": test_chat_model, # ( Not Test ) + + # OpenAI Image Models + # "gpt-image-1.5": test_image_generation_model, + # "gpt-image-1": test_image_generation_model, + ### "sora-2": test_image_generation_model, # ( Not Test ) + + # Other Chat Models + # "grok-4-1-fast-reasoning": test_chat_model, + # "grok-4-1-fast-non-reasoning": test_chat_model, + # "grok-code-fast-1": test_chat_model, + # "grok-4-0709": test_chat_model, + # "kimi-k2.5": test_chat_model, + "MiniMax-M2.5": test_chat_model, + # "glm-5": test_chat_model, + # "deepseek-v3.2": test_chat_model, +} + +if __name__ == "__main__": + passed_tests = {} + failed_tests = {} + + print(f"\n{'='*20} Starting All Tests against BASE_URL: {BASE_URL} {'='*20}\n") + if not TEST_SUITE: + print("No tests found in TEST_SUITE. Please add models to test.") + else: + for model_name, test_function in TEST_SUITE.items(): + if test_function(model_name): + passed_tests[model_name] = "Success" + else: + failed_tests[model_name] = "Failed" + print("\n") + + print(f"\n{'='*20} Finished All Tests {'='*20}\n") + + print("--- Passed Tests ---") + print(json.dumps(passed_tests, indent=2)) + + print("--- Failed Tests ---") + print(json.dumps(failed_tests, indent=2)) \ No newline at end of file