diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml deleted file mode 100644 index 87747788..00000000 --- a/.github/FUNDING.yml +++ /dev/null @@ -1,12 +0,0 @@ -# These are supported funding model platforms - -github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] -patreon: # Replace with a single Patreon username -open_collective: # Replace with a single Open Collective username -ko_fi: # Replace with a single Ko-fi username -tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel -community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry -liberapay: # Replace with a single Liberapay username -issuehunt: # Replace with a single IssueHunt username -otechie: # Replace with a single Otechie username -custom: ['https://afdian.com/a/new-api'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md index 7403f6c0..11b07ddf 100644 --- a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -1,15 +1,29 @@ -### PR 类型 +# ⚠️ 提交警告 / PR Warning +> **请注意:** 请提供**人工撰写**的简洁摘要。包含大量 AI 灌水内容、逻辑混乱或无视模版的 PR **可能会被无视或直接关闭**。 -- [ ] Bug 修复 -- [ ] 新功能 -- [ ] 文档更新 -- [ ] 其他 +--- -### PR 是否包含破坏性更新? +## 💡 沟通提示 / Pre-submission +> **重大功能变更?** 请先提交 Issue 交流,避免无效劳动。 -- [ ] 是 -- [ ] 否 +## 📝 变更描述 / Description +(简述:做了什么?为什么这样改能生效?你必须理解代码逻辑,禁止粘贴 AI 废话) -### PR 描述 +## 🚀 变更类型 / Type of change +- [ ] 🐛 Bug 修复 (Bug fix) +- [ ] ✨ 新功能 (New feature) - *重大特性建议先 Issue 沟通* +- [ ] ⚡ 性能优化 / 重构 (Refactor) +- [ ] 📝 文档更新 (Documentation) -**请在下方详细描述您的 PR,包括目的、实现细节等。** +## 🔗 关联任务 / Related Issue +- Closes # (如有) + +## ✅ 提交前检查项 / Checklist +- [ ] **人工确认:** 我已亲自撰写此描述,去除了 AI 原始输出的冗余。 +- [ ] **深度理解:** 我已**完全理解**这些更改的工作原理及潜在影响。 +- [ ] **范围聚焦:** 本 PR 未包含任何与当前任务无关的代码改动。 +- [ ] **本地验证:** 已在本地运行并通过了测试或手动验证。 +- [ ] **安全合规:** 代码中无敏感凭据,且符合项目代码规范。 + +## 📸 运行证明 / Proof of Work +(请在此粘贴截图、关键日志或测试报告,以证明变更生效) \ No newline at end of file diff --git a/.github/workflows/docker-image-alpha.yml b/.github/workflows/docker-image-alpha.yml index 2a7d43ad..116dd145 100644 --- a/.github/workflows/docker-image-alpha.yml +++ b/.github/workflows/docker-image-alpha.yml @@ -27,9 +27,10 @@ jobs: permissions: packages: write contents: read + id-token: write steps: - name: Check out (shallow) - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 @@ -46,16 +47,16 @@ jobs: run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -63,14 +64,15 @@ jobs: - name: Extract metadata (labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: | calciumion/new-api ghcr.io/${{ env.GHCR_REPOSITORY }} - name: Build & push single-arch (to both registries) - uses: docker/build-push-action@v6 + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: ${{ matrix.platform }} @@ -83,8 +85,25 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - provenance: false - sbom: false + provenance: mode=max + sbom: true + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign image with cosign + run: | + cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }} + cosign sign --yes ghcr.io/${{ env.GHCR_REPOSITORY }}@${{ steps.build.outputs.digest }} + + - name: Output digest + run: | + echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "calciumion/new-api:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY + echo "ghcr.io/${{ env.GHCR_REPOSITORY }}:alpha-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY create_manifests: name: Create multi-arch manifests (Docker Hub + GHCR) @@ -95,7 +114,7 @@ jobs: contents: read steps: - name: Check out (shallow) - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 1 @@ -110,7 +129,7 @@ jobs: echo "VERSION=$VERSION" >> $GITHUB_ENV - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -130,7 +149,7 @@ jobs: calciumion/new-api:${VERSION}-arm64 - name: Log in to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -149,3 +168,12 @@ jobs: -t ghcr.io/${GHCR_REPOSITORY}:${VERSION} \ ghcr.io/${GHCR_REPOSITORY}:${VERSION}-amd64 \ ghcr.io/${GHCR_REPOSITORY}:${VERSION}-arm64 + + - name: Output manifest digest + run: | + echo "### Multi-arch Manifest Digests" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + docker buildx imagetools inspect calciumion/new-api:alpha >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + docker buildx imagetools inspect ghcr.io/${GHCR_REPOSITORY}:alpha >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/docker-image-arm64.yml b/.github/workflows/docker-image-arm64.yml index 5b01fd90..83303ee3 100644 --- a/.github/workflows/docker-image-arm64.yml +++ b/.github/workflows/docker-image-arm64.yml @@ -4,6 +4,7 @@ on: push: tags: - '*' + - '!nightly*' workflow_dispatch: inputs: tag: @@ -29,10 +30,11 @@ jobs: permissions: packages: write contents: read + id-token: write steps: - name: Check out - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }} ref: ${{ github.event.inputs.tag || github.ref }} @@ -58,16 +60,16 @@ jobs: # run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} # - name: Log in to GHCR -# uses: docker/login-action@v3 +# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 # with: # registry: ghcr.io # username: ${{ github.actor }} @@ -75,14 +77,15 @@ jobs: - name: Extract metadata (labels) id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: | calciumion/new-api # ghcr.io/${{ env.GHCR_REPOSITORY }} - name: Build & push single-arch (to both registries) - uses: docker/build-push-action@v6 + id: build + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . platforms: ${{ matrix.platform }} @@ -95,8 +98,22 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max - provenance: false - sbom: false + provenance: mode=max + sbom: true + + - name: Install cosign + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3 + + - name: Sign image with cosign + run: cosign sign --yes calciumion/new-api@${{ steps.build.outputs.digest }} + + - name: Output digest + run: | + echo "### Docker Image Digest (${{ matrix.arch }})" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "calciumion/new-api:${{ env.TAG }}-${{ matrix.arch }}" >> $GITHUB_STEP_SUMMARY + echo "${{ steps.build.outputs.digest }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY create_manifests: name: Create multi-arch manifests (Docker Hub) @@ -116,7 +133,7 @@ jobs: # run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV - name: Log in to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -135,9 +152,16 @@ jobs: calciumion/new-api:latest-amd64 \ calciumion/new-api:latest-arm64 + - name: Output manifest digest + run: | + echo "### Multi-arch Manifest" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + docker buildx imagetools inspect calciumion/new-api:${TAG} >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + # ---- GHCR ---- # - name: Log in to GHCR -# uses: docker/login-action@v3 +# uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 # with: # registry: ghcr.io # username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a961027..1a903322 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,14 +19,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Determine Version run: | VERSION=$(git describe --tags) echo "VERSION=$VERSION" >> $GITHUB_ENV - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Build Frontend @@ -38,7 +38,7 @@ jobs: DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '>=1.25.1' - name: Build Backend (amd64) @@ -50,12 +50,16 @@ jobs: sudo apt-get update DEBIAN_FRONTEND=noninteractive sudo apt-get install -y gcc-aarch64-linux-gnu CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION' -extldflags '-static'" -o new-api-arm64-$VERSION + - name: Generate checksums + run: sha256sum new-api-* > checksums-linux.txt + - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 if: startsWith(github.ref, 'refs/tags/') with: files: | new-api-* + checksums-linux.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -64,14 +68,14 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Determine Version run: | VERSION=$(git describe --tags) echo "VERSION=$VERSION" >> $GITHUB_ENV - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Build Frontend @@ -84,18 +88,23 @@ jobs: DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '>=1.25.1' - name: Build Backend run: | go mod download go build -ldflags "-X 'new-api/common.Version=$VERSION'" -o new-api-macos-$VERSION + - name: Generate checksums + run: shasum -a 256 new-api-macos-* > checksums-macos.txt + - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 if: startsWith(github.ref, 'refs/tags/') with: - files: new-api-macos-* + files: | + new-api-macos-* + checksums-macos.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -107,14 +116,14 @@ jobs: shell: bash steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: fetch-depth: 0 - name: Determine Version run: | VERSION=$(git describe --tags) echo "VERSION=$VERSION" >> $GITHUB_ENV - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: latest - name: Build Frontend @@ -126,17 +135,22 @@ jobs: DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$VERSION bun run build cd .. - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '>=1.25.1' - name: Build Backend run: | go mod download go build -ldflags "-s -w -X 'new-api/common.Version=$VERSION'" -o new-api-$VERSION.exe + - name: Generate checksums + run: sha256sum new-api-*.exe > checksums-windows.txt + - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 if: startsWith(github.ref, 'refs/tags/') with: - files: new-api-*.exe + files: | + new-api-*.exe + checksums-windows.txt env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Dockerfile b/Dockerfile index aa43de1c..93279163 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM oven/bun:latest AS builder +FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder WORKDIR /build COPY web/package.json . @@ -8,7 +8,7 @@ COPY ./web . COPY ./VERSION . RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build -FROM golang:alpine AS builder2 +FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS @@ -25,7 +25,7 @@ COPY . . COPY --from=builder /build/dist ./web/dist RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata libasan8 wget \ diff --git a/README.zh_CN.md b/README.zh_CN.md index fd320495..92e5baa1 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -383,7 +383,7 @@ docker run --name new-api -d --restart always \ 2. 在应用商店搜索 **New-API** 3. 一键安装 -📖 [图文教程](./docs/BT.md) +📖 [图文教程](./docs/installation/BT.md) diff --git a/README.zh_TW.md b/README.zh_TW.md index 9264bc72..63664f0c 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -70,17 +70,20 @@
diff --git a/common/constants.go b/common/constants.go index 6823b2c8..5118da70 100644 --- a/common/constants.go +++ b/common/constants.go @@ -177,6 +177,7 @@ var ( DownloadRateLimitDuration int64 = 60 // Per-user search rate limit (applies after authentication, keyed by user ID) + SearchRateLimitEnable = true SearchRateLimitNum = 10 SearchRateLimitDuration int64 = 60 ) @@ -211,5 +212,6 @@ const ( const ( TopUpStatusPending = "pending" TopUpStatusSuccess = "success" + TopUpStatusFailed = "failed" TopUpStatusExpired = "expired" ) diff --git a/common/gin.go b/common/gin.go index 5cad6e5c..da7f8be4 100644 --- a/common/gin.go +++ b/common/gin.go @@ -229,6 +229,7 @@ func init() { // Default implementation that returns the key as-is // This will be replaced by i18n.T during i18n initialization TranslateMessage = func(c *gin.Context, key string, args ...map[string]any) string { + c.Header("X-Translate-id", "d5e7afdfc7f03414b941f9c1e7096be9966510e7") return key } } diff --git a/common/init.go b/common/init.go index e4ddbb45..e9cfc98b 100644 --- a/common/init.go +++ b/common/init.go @@ -120,6 +120,10 @@ func InitEnv() { CriticalRateLimitEnable = GetEnvOrDefaultBool("CRITICAL_RATE_LIMIT_ENABLE", true) CriticalRateLimitNum = GetEnvOrDefault("CRITICAL_RATE_LIMIT", 20) CriticalRateLimitDuration = int64(GetEnvOrDefault("CRITICAL_RATE_LIMIT_DURATION", 20*60)) + + SearchRateLimitEnable = GetEnvOrDefaultBool("SEARCH_RATE_LIMIT_ENABLE", true) + SearchRateLimitNum = GetEnvOrDefault("SEARCH_RATE_LIMIT", 10) + SearchRateLimitDuration = int64(GetEnvOrDefault("SEARCH_RATE_LIMIT_DURATION", 60)) initConstantEnv() } @@ -127,7 +131,7 @@ func initConstantEnv() { constant.StreamingTimeout = GetEnvOrDefault("STREAMING_TIMEOUT", 300) constant.DifyDebug = GetEnvOrDefaultBool("DIFY_DEBUG", true) constant.MaxFileDownloadMB = GetEnvOrDefault("MAX_FILE_DOWNLOAD_MB", 64) - constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 64) + constant.StreamScannerMaxBufferMB = GetEnvOrDefault("STREAM_SCANNER_MAX_BUFFER_MB", 128) // MaxRequestBodyMB 请求体最大大小(解压后),用于防止超大请求/zip bomb导致内存暴涨 constant.MaxRequestBodyMB = GetEnvOrDefault("MAX_REQUEST_BODY_MB", 128) // ForceStreamOption 覆盖请求参数,强制返回usage信息 diff --git a/common/sys_log.go b/common/sys_log.go index b29adc3e..6e5b3622 100644 --- a/common/sys_log.go +++ b/common/sys_log.go @@ -3,53 +3,60 @@ package common import ( "fmt" "os" + "sync" "time" "github.com/gin-gonic/gin" ) +// LogWriterMu protects concurrent access to gin.DefaultWriter/gin.DefaultErrorWriter +// during log file rotation. Acquire RLock when reading/writing through the writers, +// acquire Lock when swapping writers and closing old files. +var LogWriterMu sync.RWMutex + func SysLog(s string) { t := time.Now() + LogWriterMu.RLock() _, _ = fmt.Fprintf(gin.DefaultWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) + LogWriterMu.RUnlock() } func SysError(s string) { t := time.Now() + LogWriterMu.RLock() _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[SYS] %v | %s \n", t.Format("2006/01/02 - 15:04:05"), s) + LogWriterMu.RUnlock() } func FatalLog(v ...any) { t := time.Now() + LogWriterMu.RLock() _, _ = fmt.Fprintf(gin.DefaultErrorWriter, "[FATAL] %v | %v \n", t.Format("2006/01/02 - 15:04:05"), v) + LogWriterMu.RUnlock() os.Exit(1) } func LogStartupSuccess(startTime time.Time, port string) { - duration := time.Since(startTime) durationMs := duration.Milliseconds() // Get network IPs networkIps := GetNetworkIps() - // Print blank line for spacing - fmt.Fprintf(gin.DefaultWriter, "\n") + LogWriterMu.RLock() + defer LogWriterMu.RUnlock() - // Print the main success message + fmt.Fprintf(gin.DefaultWriter, "\n") fmt.Fprintf(gin.DefaultWriter, " \033[32m%s %s\033[0m ready in %d ms\n", SystemName, Version, durationMs) fmt.Fprintf(gin.DefaultWriter, "\n") - // Skip fancy startup message in container environments if !IsRunningInContainer() { - // Print local URL fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mLocal:\033[0m http://localhost:%s/\n", port) } - // Print network URLs for _, ip := range networkIps { fmt.Fprintf(gin.DefaultWriter, " ➜ \033[1mNetwork:\033[0m http://%s:%s/\n", ip, port) } - // Print blank line for spacing fmt.Fprintf(gin.DefaultWriter, "\n") } diff --git a/constant/waffo_pay_method.go b/constant/waffo_pay_method.go new file mode 100644 index 00000000..0cee72a8 --- /dev/null +++ b/constant/waffo_pay_method.go @@ -0,0 +1,16 @@ +package constant + +// WaffoPayMethod defines the display and API parameter mapping for Waffo payment methods. +type WaffoPayMethod struct { + Name string `json:"name"` // Frontend display name + Icon string `json:"icon"` // Frontend icon identifier: credit-card, apple, google + PayMethodType string `json:"payMethodType"` // Waffo API PayMethodType, can be comma-separated + PayMethodName string `json:"payMethodName"` // Waffo API PayMethodName, empty means auto-select by Waffo checkout +} + +// DefaultWaffoPayMethods is the default list of supported payment methods. +var DefaultWaffoPayMethods = []WaffoPayMethod{ + {Name: "Card", Icon: "/pay-card.png", PayMethodType: "CREDITCARD,DEBITCARD", PayMethodName: ""}, + {Name: "Apple Pay", Icon: "/pay-apple.png", PayMethodType: "APPLEPAY", PayMethodName: "APPLEPAY"}, + {Name: "Google Pay", Icon: "/pay-google.png", PayMethodType: "GOOGLEPAY", PayMethodName: "GOOGLEPAY"}, +} diff --git a/controller/channel_upstream_update.go b/controller/channel_upstream_update.go index 1062adb1..1d851949 100644 --- a/controller/channel_upstream_update.go +++ b/controller/channel_upstream_update.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "regexp" "slices" "strings" "sync" @@ -169,10 +170,7 @@ func collectPendingUpstreamModelChangesFromModels( upstreamSet[modelName] = struct{}{} } - ignoredSet := make(map[string]struct{}) - for _, modelName := range normalizeModelNames(ignoredModels) { - ignoredSet[modelName] = struct{}{} - } + normalizedIgnoredModels := normalizeModelNames(ignoredModels) redirectSourceSet := make(map[string]struct{}, len(modelMapping)) redirectTargetSet := make(map[string]struct{}, len(modelMapping)) @@ -193,7 +191,13 @@ func collectPendingUpstreamModelChangesFromModels( if _, ok := coveredUpstreamSet[modelName]; ok { return false } - if _, ok := ignoredSet[modelName]; ok { + if lo.ContainsBy(normalizedIgnoredModels, func(ignoredModel string) bool { + if regexBody, ok := strings.CutPrefix(ignoredModel, "regex:"); ok { + matched, err := regexp.MatchString(strings.TrimSpace(regexBody), modelName) + return err == nil && matched + } + return ignoredModel == modelName + }) { return false } return true diff --git a/controller/channel_upstream_update_test.go b/controller/channel_upstream_update_test.go index 153119d4..52de830b 100644 --- a/controller/channel_upstream_update_test.go +++ b/controller/channel_upstream_update_test.go @@ -111,6 +111,18 @@ func TestCollectPendingUpstreamModelChangesFromModels_WithModelMapping(t *testin require.Equal(t, []string{"stale-model"}, pendingRemoveModels) } +func TestCollectPendingUpstreamModelChangesFromModels_WithIgnoredRegexPatterns(t *testing.T) { + pendingAddModels, pendingRemoveModels := collectPendingUpstreamModelChangesFromModels( + []string{"gpt-4o"}, + []string{"gpt-4o", "claude-3-5-sonnet", "sora-video", "gpt-4.1"}, + []string{"regex:^sora-.*$", "gpt-4.1"}, + nil, + ) + + require.Equal(t, []string{"claude-3-5-sonnet"}, pendingAddModels) + require.Equal(t, []string{}, pendingRemoveModels) +} + func TestBuildUpstreamModelUpdateTaskNotificationContent_OmitOverflowDetails(t *testing.T) { channelSummaries := make([]upstreamModelUpdateChannelSummary, 0, 12) for i := 0; i < 12; i++ { diff --git a/controller/misc.go b/controller/misc.go index b24a74ad..519caed5 100644 --- a/controller/misc.go +++ b/controller/misc.go @@ -8,6 +8,7 @@ import ( "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/middleware" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/oauth" @@ -116,7 +117,6 @@ func GetStatus(c *gin.Context) { "user_agreement_enabled": legalSetting.UserAgreement != "", "privacy_policy_enabled": legalSetting.PrivacyPolicy != "", "checkin_enabled": operation_setting.GetCheckinSetting().Enabled, - "_qn": "new-api", } // 根据启用状态注入可选内容 @@ -308,31 +308,24 @@ func SendPasswordResetEmail(c *gin.Context) { }) return } - if !model.IsEmailAlreadyTaken(email) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "该邮箱地址未注册", - }) - return - } - code := common.GenerateVerificationCode(0) - common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) - link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) - subject := fmt.Sprintf("%s密码重置", common.SystemName) - content := fmt.Sprintf("您好,你正在进行%s密码重置。
"+ - "点击 此处 进行密码重置。
"+ - "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s
重置链接 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, link, link, common.VerificationValidMinutes) - err := common.SendEmail(subject, email, content) - if err != nil { - common.ApiError(c, err) - return + if model.IsEmailAlreadyTaken(email) { + code := common.GenerateVerificationCode(0) + common.RegisterVerificationCodeWithKey(email, code, common.PasswordResetPurpose) + link := fmt.Sprintf("%s/user/reset?email=%s&token=%s", system_setting.ServerAddress, email, code) + subject := fmt.Sprintf("%s密码重置", common.SystemName) + content := fmt.Sprintf("您好,你正在进行%s密码重置。
"+ + "点击 此处 进行密码重置。
"+ + "如果链接无法点击,请尝试点击下面的链接或将其复制到浏览器中打开:
%s
重置链接 %d 分钟内有效,如果不是本人操作,请忽略。
", common.SystemName, link, link, common.VerificationValidMinutes) + err := common.SendEmail(subject, email, content) + if err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("failed to send password reset email to %s: %s", email, err.Error())) + } } c.JSON(http.StatusOK, gin.H{ "success": true, "message": "", }) - return } type PasswordResetRequest struct { diff --git a/controller/oauth.go b/controller/oauth.go index 818a28f8..9951f22b 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -190,7 +190,9 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider) { } } - common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, nil) + common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{ + "action": "bind", + }) } // findOrCreateOAuthUser finds existing user or creates new user diff --git a/controller/performance.go b/controller/performance.go index 8e5281e9..69324475 100644 --- a/controller/performance.go +++ b/controller/performance.go @@ -1,12 +1,18 @@ package controller import ( + "fmt" "net/http" "os" + "path/filepath" "runtime" + "sort" + "strconv" + "strings" "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/logger" "github.com/gin-gonic/gin" ) @@ -169,6 +175,183 @@ func ForceGC(c *gin.Context) { }) } +// LogFileInfo 日志文件信息 +type LogFileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModTime time.Time `json:"mod_time"` +} + +// LogFilesResponse 日志文件列表响应 +type LogFilesResponse struct { + LogDir string `json:"log_dir"` + Enabled bool `json:"enabled"` + FileCount int `json:"file_count"` + TotalSize int64 `json:"total_size"` + OldestTime *time.Time `json:"oldest_time,omitempty"` + NewestTime *time.Time `json:"newest_time,omitempty"` + Files []LogFileInfo `json:"files"` +} + +// getLogFiles 读取日志目录中的日志文件列表 +func getLogFiles() ([]LogFileInfo, error) { + if *common.LogDir == "" { + return nil, nil + } + entries, err := os.ReadDir(*common.LogDir) + if err != nil { + return nil, err + } + var files []LogFileInfo + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, "oneapi-") || !strings.HasSuffix(name, ".log") { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + files = append(files, LogFileInfo{ + Name: name, + Size: info.Size(), + ModTime: info.ModTime(), + }) + } + // 按文件名降序排列(最新在前) + sort.Slice(files, func(i, j int) bool { + return files[i].Name > files[j].Name + }) + return files, nil +} + +// GetLogFiles 获取日志文件列表 +func GetLogFiles(c *gin.Context) { + if *common.LogDir == "" { + common.ApiSuccess(c, LogFilesResponse{Enabled: false}) + return + } + files, err := getLogFiles() + if err != nil { + common.ApiError(c, err) + return + } + var totalSize int64 + var oldest, newest time.Time + for i, f := range files { + totalSize += f.Size + if i == 0 || f.ModTime.Before(oldest) { + oldest = f.ModTime + } + if i == 0 || f.ModTime.After(newest) { + newest = f.ModTime + } + } + resp := LogFilesResponse{ + LogDir: *common.LogDir, + Enabled: true, + FileCount: len(files), + TotalSize: totalSize, + Files: files, + } + if len(files) > 0 { + resp.OldestTime = &oldest + resp.NewestTime = &newest + } + common.ApiSuccess(c, resp) +} + +// CleanupLogFiles 清理过期日志文件 +func CleanupLogFiles(c *gin.Context) { + mode := c.Query("mode") + valueStr := c.Query("value") + if mode != "by_count" && mode != "by_days" { + common.ApiErrorMsg(c, "invalid mode, must be by_count or by_days") + return + } + value, err := strconv.Atoi(valueStr) + if err != nil || value < 1 { + common.ApiErrorMsg(c, "invalid value, must be a positive integer") + return + } + if *common.LogDir == "" { + common.ApiErrorMsg(c, "log directory not configured") + return + } + + files, err := getLogFiles() + if err != nil { + common.ApiError(c, err) + return + } + + activeLogPath := logger.GetCurrentLogPath() + var toDelete []LogFileInfo + + switch mode { + case "by_count": + // files 已按名称降序(最新在前),保留前 value 个 + for i, f := range files { + if i < value { + continue + } + fullPath := filepath.Join(*common.LogDir, f.Name) + if fullPath == activeLogPath { + continue + } + toDelete = append(toDelete, f) + } + case "by_days": + cutoff := time.Now().AddDate(0, 0, -value) + for _, f := range files { + if f.ModTime.Before(cutoff) { + fullPath := filepath.Join(*common.LogDir, f.Name) + if fullPath == activeLogPath { + continue + } + toDelete = append(toDelete, f) + } + } + } + + var deletedCount int + var freedBytes int64 + var failedFiles []string + for _, f := range toDelete { + fullPath := filepath.Join(*common.LogDir, f.Name) + if err := os.Remove(fullPath); err != nil { + failedFiles = append(failedFiles, f.Name) + continue + } + deletedCount++ + freedBytes += f.Size + } + + result := gin.H{ + "deleted_count": deletedCount, + "freed_bytes": freedBytes, + "failed_files": failedFiles, + } + + if len(failedFiles) > 0 { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": fmt.Sprintf("部分文件删除失败(%d/%d)", len(failedFiles), len(toDelete)), + "data": result, + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": result, + }) +} + // getDiskCacheInfo 获取磁盘缓存目录信息 func getDiskCacheInfo() DiskCacheInfo { // 使用统一的缓存目录 diff --git a/controller/pricing.go b/controller/pricing.go index b6537e4c..d0736119 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -46,7 +46,7 @@ func GetPricing(c *gin.Context) { "usable_group": usableGroup, "supported_endpoint": model.GetSupportedEndpointMap(), "auto_groups": service.GetUserAutoGroup(group), - "_": "a42d372ccf0b5dd13ecf71203521f9d2", + "pricing_version": "a42d372ccf0b5dd13ecf71203521f9d2", }) } diff --git a/controller/topup.go b/controller/topup.go index a810eba7..e7a392a4 100644 --- a/controller/topup.go +++ b/controller/topup.go @@ -48,14 +48,52 @@ func GetTopUpInfo(c *gin.Context) { } } + // 如果启用了 Waffo 支付,添加到支付方法列表 + enableWaffo := setting.WaffoEnabled && + ((!setting.WaffoSandbox && + setting.WaffoApiKey != "" && + setting.WaffoPrivateKey != "" && + setting.WaffoPublicCert != "") || + (setting.WaffoSandbox && + setting.WaffoSandboxApiKey != "" && + setting.WaffoSandboxPrivateKey != "" && + setting.WaffoSandboxPublicCert != "")) + if enableWaffo { + hasWaffo := false + for _, method := range payMethods { + if method["type"] == "waffo" { + hasWaffo = true + break + } + } + + if !hasWaffo { + waffoMethod := map[string]string{ + "name": "Waffo (Global Payment)", + "type": "waffo", + "color": "rgba(var(--semi-blue-5), 1)", + "min_topup": strconv.Itoa(setting.WaffoMinTopUp), + } + payMethods = append(payMethods, waffoMethod) + } + } + data := gin.H{ "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 != "[]", - "creem_products": setting.CreemProducts, + "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, } @@ -204,27 +242,42 @@ func RequestEpay(c *gin.Context) { var orderLocks sync.Map var createLock sync.Mutex +// refCountedMutex 带引用计数的互斥锁,确保最后一个使用者才从 map 中删除 +type refCountedMutex struct { + mu sync.Mutex + refCount int +} + // LockOrder 尝试对给定订单号加锁 func LockOrder(tradeNo string) { - lock, ok := orderLocks.Load(tradeNo) - if !ok { - createLock.Lock() - defer createLock.Unlock() - lock, ok = orderLocks.Load(tradeNo) - if !ok { - lock = new(sync.Mutex) - orderLocks.Store(tradeNo, lock) - } + createLock.Lock() + var rcm *refCountedMutex + if v, ok := orderLocks.Load(tradeNo); ok { + rcm = v.(*refCountedMutex) + } else { + rcm = &refCountedMutex{} + orderLocks.Store(tradeNo, rcm) } - lock.(*sync.Mutex).Lock() + rcm.refCount++ + createLock.Unlock() + rcm.mu.Lock() } // UnlockOrder 释放给定订单号的锁 func UnlockOrder(tradeNo string) { - lock, ok := orderLocks.Load(tradeNo) - if ok { - lock.(*sync.Mutex).Unlock() + v, ok := orderLocks.Load(tradeNo) + if !ok { + return } + rcm := v.(*refCountedMutex) + rcm.mu.Unlock() + + createLock.Lock() + rcm.refCount-- + if rcm.refCount == 0 { + orderLocks.Delete(tradeNo) + } + createLock.Unlock() } func EpayNotify(c *gin.Context) { @@ -410,3 +463,4 @@ func AdminCompleteTopUp(c *gin.Context) { } common.ApiSuccess(c, nil) } + diff --git a/controller/topup_waffo.go b/controller/topup_waffo.go new file mode 100644 index 00000000..fce37642 --- /dev/null +++ b/controller/topup_waffo.go @@ -0,0 +1,380 @@ +package controller + +import ( + "fmt" + "io" + "log" + "net/http" + "strconv" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/gin-gonic/gin" + "github.com/thanhpk/randstr" + waffo "github.com/waffo-com/waffo-go" + "github.com/waffo-com/waffo-go/config" + "github.com/waffo-com/waffo-go/core" + "github.com/waffo-com/waffo-go/types/order" +) + +func getWaffoSDK() (*waffo.Waffo, error) { + env := config.Sandbox + apiKey := setting.WaffoSandboxApiKey + privateKey := setting.WaffoSandboxPrivateKey + publicKey := setting.WaffoSandboxPublicCert + if !setting.WaffoSandbox { + env = config.Production + apiKey = setting.WaffoApiKey + privateKey = setting.WaffoPrivateKey + publicKey = setting.WaffoPublicCert + } + builder := config.NewConfigBuilder(). + APIKey(apiKey). + PrivateKey(privateKey). + WaffoPublicKey(publicKey). + Environment(env) + if setting.WaffoMerchantId != "" { + builder = builder.MerchantID(setting.WaffoMerchantId) + } + cfg, err := builder.Build() + if err != nil { + return nil, err + } + return waffo.New(cfg), nil +} + +func getWaffoUserEmail(user *model.User) string { + return fmt.Sprintf("%d@examples.com", user.Id) +} + +func getWaffoCurrency() string { + if setting.WaffoCurrency != "" { + return setting.WaffoCurrency + } + return "USD" +} + +// zeroDecimalCurrencies 零小数位币种,金额不能带小数点 +var zeroDecimalCurrencies = map[string]bool{ + "IDR": true, "JPY": true, "KRW": true, "VND": true, +} + +func formatWaffoAmount(amount float64, currency string) string { + if zeroDecimalCurrencies[currency] { + return fmt.Sprintf("%.0f", amount) + } + return fmt.Sprintf("%.2f", amount) +} + +// getWaffoPayMoney converts the user-facing amount to USD for Waffo payment. +// Waffo only accepts USD, so this function handles the conversion from different +// display types (USD/CNY/TOKENS) to the actual USD amount to charge. +func getWaffoPayMoney(amount float64, group string) float64 { + originalAmount := amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + amount = amount / common.QuotaPerUnit + } + topupGroupRatio := common.GetTopupGroupRatio(group) + if topupGroupRatio == 0 { + topupGroupRatio = 1 + } + discount := 1.0 + if ds, ok := operation_setting.GetPaymentSetting().AmountDiscount[int(originalAmount)]; ok { + if ds > 0 { + discount = ds + } + } + return amount * setting.WaffoUnitPrice * topupGroupRatio * discount +} + +type WaffoPayRequest struct { + Amount int64 `json:"amount"` + PayMethodIndex *int `json:"pay_method_index"` // 服务端支付方式列表的索引,nil 表示由 Waffo 自动选择 + PayMethodType string `json:"pay_method_type"` // Deprecated: 兼容旧前端,优先使用 pay_method_index + PayMethodName string `json:"pay_method_name"` // Deprecated: 兼容旧前端,优先使用 pay_method_index +} + +// RequestWaffoPay 创建 Waffo 支付订单 +func RequestWaffoPay(c *gin.Context) { + if !setting.WaffoEnabled { + c.JSON(200, gin.H{"message": "error", "data": "Waffo 支付未启用"}) + return + } + + var req WaffoPayRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(200, gin.H{"message": "error", "data": "参数错误"}) + return + } + waffoMinTopup := int64(setting.WaffoMinTopUp) + if req.Amount < waffoMinTopup { + c.JSON(200, gin.H{"message": "error", "data": fmt.Sprintf("充值数量不能小于 %d", waffoMinTopup)}) + return + } + + id := c.GetInt("id") + user, err := model.GetUserById(id, false) + if err != nil || user == nil { + c.JSON(200, gin.H{"message": "error", "data": "用户不存在"}) + return + } + + // 从服务端配置查找支付方式,客户端只传索引或旧字段 + var resolvedPayMethodType, resolvedPayMethodName string + methods := setting.GetWaffoPayMethods() + if req.PayMethodIndex != nil { + // 新协议:按索引查找 + idx := *req.PayMethodIndex + if idx < 0 || idx >= len(methods) { + log.Printf("Waffo 无效的支付方式索引: %d, UserId=%d, 可用范围: [0, %d)", idx, id, len(methods)) + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"}) + return + } + resolvedPayMethodType = methods[idx].PayMethodType + resolvedPayMethodName = methods[idx].PayMethodName + } else if req.PayMethodType != "" { + // 兼容旧前端:验证客户端传的值在服务端列表中 + valid := false + for _, m := range methods { + if m.PayMethodType == req.PayMethodType && m.PayMethodName == req.PayMethodName { + valid = true + resolvedPayMethodType = m.PayMethodType + resolvedPayMethodName = m.PayMethodName + break + } + } + if !valid { + log.Printf("Waffo 无效的支付方式: PayMethodType=%s, PayMethodName=%s, UserId=%d", req.PayMethodType, req.PayMethodName, id) + c.JSON(200, gin.H{"message": "error", "data": "不支持的支付方式"}) + return + } + } + // resolvedPayMethodType/Name 为空时,Waffo 自动选择支付方式 + + group, _ := model.GetUserGroup(id, true) + payMoney := getWaffoPayMoney(float64(req.Amount), group) + if payMoney < 0.01 { + c.JSON(200, gin.H{"message": "error", "data": "充值金额过低"}) + return + } + + // 生成唯一订单号,paymentRequestId 与 merchantOrderId 保持一致,简化追踪 + merchantOrderId := fmt.Sprintf("WAFFO-%d-%d-%s", id, time.Now().UnixMilli(), randstr.String(6)) + paymentRequestId := merchantOrderId + + // Token 模式下归一化 Amount(存等价美元/CNY 数量,避免 RechargeWaffo 双重放大) + amount := req.Amount + if operation_setting.GetQuotaDisplayType() == operation_setting.QuotaDisplayTypeTokens { + amount = int64(float64(req.Amount) / common.QuotaPerUnit) + if amount < 1 { + amount = 1 + } + } + + // 创建本地订单 + topUp := &model.TopUp{ + UserId: id, + Amount: amount, + Money: payMoney, + TradeNo: merchantOrderId, + PaymentMethod: "waffo", + CreateTime: time.Now().Unix(), + Status: common.TopUpStatusPending, + } + if err := topUp.Insert(); err != nil { + log.Printf("Waffo 创建本地订单失败: %v", err) + c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"}) + return + } + + sdk, err := getWaffoSDK() + if err != nil { + log.Printf("Waffo SDK 初始化失败: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "支付配置错误"}) + return + } + + callbackAddr := service.GetCallbackAddress() + notifyUrl := callbackAddr + "/api/waffo/webhook" + if setting.WaffoNotifyUrl != "" { + notifyUrl = setting.WaffoNotifyUrl + } + returnUrl := system_setting.ServerAddress + "/console/topup?show_history=true" + if setting.WaffoReturnUrl != "" { + returnUrl = setting.WaffoReturnUrl + } + + currency := getWaffoCurrency() + createParams := &order.CreateOrderParams{ + PaymentRequestID: paymentRequestId, + MerchantOrderID: merchantOrderId, + OrderAmount: formatWaffoAmount(payMoney, currency), + OrderCurrency: currency, + OrderDescription: fmt.Sprintf("Recharge %d credits", req.Amount), + OrderRequestedAt: time.Now().UTC().Format("2006-01-02T15:04:05.000Z"), + NotifyURL: notifyUrl, + MerchantInfo: &order.MerchantInfo{ + MerchantID: setting.WaffoMerchantId, + }, + UserInfo: &order.UserInfo{ + UserID: strconv.Itoa(user.Id), + UserEmail: getWaffoUserEmail(user), + UserTerminal: "WEB", + }, + PaymentInfo: &order.PaymentInfo{ + ProductName: "ONE_TIME_PAYMENT", + PayMethodType: resolvedPayMethodType, + PayMethodName: resolvedPayMethodName, + }, + SuccessRedirectURL: returnUrl, + FailedRedirectURL: returnUrl, + } + resp, err := sdk.Order().Create(c.Request.Context(), createParams, nil) + if err != nil { + log.Printf("Waffo 创建订单失败: %v", err) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + if !resp.IsSuccess() { + log.Printf("Waffo 创建订单业务失败: [%s] %s, 完整响应: %+v", resp.Code, resp.Message, resp) + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"}) + return + } + + orderData := resp.GetData() + log.Printf("Waffo 订单创建成功 - 用户: %d, 订单: %s, 金额: %.2f", id, merchantOrderId, payMoney) + + paymentUrl := orderData.FetchRedirectURL() + if paymentUrl == "" { + paymentUrl = orderData.OrderAction + } + + c.JSON(200, gin.H{ + "message": "success", + "data": gin.H{ + "payment_url": paymentUrl, + "order_id": merchantOrderId, + }, + }) +} + +// webhookPayloadWithSubInfo 扩展 PAYMENT_NOTIFICATION,包含 SDK 未定义的 subscriptionInfo 字段 +type webhookPayloadWithSubInfo struct { + EventType string `json:"eventType"` + Result struct { + core.PaymentNotificationResult + SubscriptionInfo *webhookSubscriptionInfo `json:"subscriptionInfo,omitempty"` + } `json:"result"` +} + +type webhookSubscriptionInfo struct { + Period string `json:"period,omitempty"` + MerchantRequest string `json:"merchantRequest,omitempty"` + SubscriptionID string `json:"subscriptionId,omitempty"` + SubscriptionRequest string `json:"subscriptionRequest,omitempty"` +} + +// WaffoWebhook 处理 Waffo 回调通知(支付/退款/订阅) +func WaffoWebhook(c *gin.Context) { + bodyBytes, err := io.ReadAll(c.Request.Body) + if err != nil { + log.Printf("Waffo Webhook 读取 body 失败: %v", err) + c.AbortWithStatus(http.StatusBadRequest) + return + } + + sdk, err := getWaffoSDK() + if err != nil { + log.Printf("Waffo Webhook SDK 初始化失败: %v", err) + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + wh := sdk.Webhook() + bodyStr := string(bodyBytes) + signature := c.GetHeader("X-SIGNATURE") + + // 验证请求签名 + if !wh.VerifySignature(bodyStr, signature) { + log.Printf("Waffo webhook 签名验证失败") + c.AbortWithStatus(http.StatusBadRequest) + return + } + + var event core.WebhookEvent + if err := common.Unmarshal(bodyBytes, &event); err != nil { + log.Printf("Waffo Webhook 解析失败: %v", err) + sendWaffoWebhookResponse(c, wh, false, "invalid payload") + return + } + + switch event.EventType { + case core.EventPayment: + // 解析为扩展类型,区分普通支付和订阅支付 + var payload webhookPayloadWithSubInfo + if err := common.Unmarshal(bodyBytes, &payload); err != nil { + sendWaffoWebhookResponse(c, wh, false, "invalid payment payload") + return + } + log.Printf("Waffo Webhook - EventType: %s, MerchantOrderId: %s, OrderStatus: %s", + event.EventType, payload.Result.MerchantOrderID, payload.Result.OrderStatus) + handleWaffoPayment(c, wh, &payload.Result.PaymentNotificationResult) + default: + log.Printf("Waffo Webhook 未知事件: %s", event.EventType) + sendWaffoWebhookResponse(c, wh, true, "") + } +} + +// handleWaffoPayment 处理支付完成通知 +func handleWaffoPayment(c *gin.Context, wh *core.WebhookHandler, result *core.PaymentNotificationResult) { + if result.OrderStatus != "PAY_SUCCESS" { + log.Printf("Waffo 订单状态非成功: %s, 订单: %s", result.OrderStatus, result.MerchantOrderID) + // 终态失败订单标记为 failed,避免永远停在 pending + if result.MerchantOrderID != "" { + if topUp := model.GetTopUpByTradeNo(result.MerchantOrderID); topUp != nil && + topUp.Status == common.TopUpStatusPending { + topUp.Status = common.TopUpStatusFailed + _ = topUp.Update() + } + } + sendWaffoWebhookResponse(c, wh, true, "") + return + } + + merchantOrderId := result.MerchantOrderID + + LockOrder(merchantOrderId) + defer UnlockOrder(merchantOrderId) + + if err := model.RechargeWaffo(merchantOrderId); err != nil { + log.Printf("Waffo 充值处理失败: %v, 订单: %s", err, merchantOrderId) + sendWaffoWebhookResponse(c, wh, false, err.Error()) + return + } + + log.Printf("Waffo 充值成功 - 订单: %s", merchantOrderId) + sendWaffoWebhookResponse(c, wh, true, "") +} + +// sendWaffoWebhookResponse 发送签名响应 +func sendWaffoWebhookResponse(c *gin.Context, wh *core.WebhookHandler, success bool, msg string) { + var body, sig string + if success { + body, sig = wh.BuildSuccessResponse() + } else { + body, sig = wh.BuildFailedResponse(msg) + } + c.Header("X-SIGNATURE", sig) + c.Data(http.StatusOK, "application/json", []byte(body)) +} diff --git a/controller/user.go b/controller/user.go index 4ec64e29..8229d0d2 100644 --- a/controller/user.go +++ b/controller/user.go @@ -925,9 +925,19 @@ func ManageUser(c *gin.Context) { return } +type emailBindRequest struct { + Email string `json:"email"` + Code string `json:"code"` +} + func EmailBind(c *gin.Context) { - email := c.Query("email") - code := c.Query("code") + var req emailBindRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + common.ApiError(c, errors.New("invalid request body")) + return + } + email := req.Email + code := req.Code if !common.VerifyCodeWithKey(email, code, common.EmailVerificationPurpose) { common.ApiErrorI18n(c, i18n.MsgUserVerificationCodeError) return diff --git a/controller/video_proxy.go b/controller/video_proxy.go index 5532802e..520d313a 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -10,10 +10,12 @@ import ( "strings" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) @@ -127,6 +129,13 @@ func VideoProxy(c *gin.Context) { return } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err)) + videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) + return + } + req.URL, err = url.Parse(videoURL) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to parse URL %s: %s", videoURL, err.Error())) diff --git a/controller/wechat.go b/controller/wechat.go index 07f2fb32..8889daca 100644 --- a/controller/wechat.go +++ b/controller/wechat.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "strconv" "time" @@ -25,7 +26,7 @@ func getWeChatIdByCode(code string) (string, error) { if code == "" { return "", errors.New("无效的参数") } - req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, code), nil) + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/wechat/user?code=%s", common.WeChatServerAddress, url.QueryEscape(code)), nil) if err != nil { return "", err } @@ -121,6 +122,10 @@ func WeChatAuth(c *gin.Context) { setupLogin(&user, c) } +type wechatBindRequest struct { + Code string `json:"code"` +} + func WeChatBind(c *gin.Context) { if !common.WeChatAuthEnabled { c.JSON(http.StatusOK, gin.H{ @@ -129,7 +134,15 @@ func WeChatBind(c *gin.Context) { }) return } - code := c.Query("code") + var req wechatBindRequest + if err := common.DecodeJson(c.Request.Body, &req); err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "无效的请求", + }) + return + } + code := req.Code wechatId, err := getWeChatIdByCode(code) if err != nil { c.JSON(http.StatusOK, gin.H{ diff --git a/docs/installation/BT.md b/docs/installation/BT.md index b4ea5b2f..8579b354 100644 --- a/docs/installation/BT.md +++ b/docs/installation/BT.md @@ -1,3 +1,151 @@ -密钥为环境变量SESSION_SECRET +# 宝塔面板部署教程 + +本文档提供使用宝塔面板 Docker 功能部署 New API 的图文教程。 + +> 📖 官方文档:[宝塔面板部署](https://docs.newapi.pro/zh/docs/installation/deployment-methods/bt-docker-installation) + +*** + +## 前置要求 + +| 项目 | 要求 | +| ----- | ---------------------------------- | +| 宝塔面板 | ≥ 9.2.0 版本 | +| 推荐系统 | CentOS 7+、Ubuntu 18.04+、Debian 10+ | +| 服务器配置 | 至少 1 核 2G 内存 | + +*** + +## 步骤一:安装宝塔面板 + +1. 前往 [宝塔面板官网](https://www.bt.cn/new/download.html) 下载适合您系统的安装脚本 +2. 运行安装脚本安装宝塔面板 +3. 安装完成后,使用提供的地址、用户名和密码登录宝塔面板 + +*** + +## 步骤二:安装 Docker + +1. 登录宝塔面板后,在左侧菜单栏找到并点击 **Docker** +2. 首次进入会提示安装 Docker 服务,点击 **立即安装** +3. 按照提示完成 Docker 服务的安装 + +*** + +## 步骤三:安装 New API + +### 方法一:使用宝塔应用商店(推荐) + +1. 在宝塔面板 Docker 功能中,点击 **应用商店** +2. 搜索并找到 **New-API** +3. 点击 **安装** +4. 配置以下基本选项: + - **容器名称**:可自定义,默认为 `new-api` + - **端口映射**:默认为 `3000:3000` + - **环境变量**: + - `SESSION_SECRET`:会话密钥(**必填**,多机部署时必须一致) + - `CRYPTO_SECRET`:加密密钥(使用 Redis 时必填) +5. 点击 **确认** 开始安装 +6. 等待安装完成后,访问 `http://您的服务器IP:3000` 即可使用 + +### 方法二:使用 Docker Compose + +1. 在宝塔面板中创建网站目录,如 `/www/wwwroot/new-api` +2. 创建 `docker-compose.yml` 文件: + +```yaml +version: '3' +services: + new-api: + image: calciumion/new-api:latest + container_name: new-api + restart: always + ports: + - "3000:3000" + volumes: + - ./data:/data + environment: + - SESSION_SECRET=your_session_secret_here # 请修改为随机字符串 + - TZ=Asia/Shanghai +``` + +1. 在终端中进入目录并启动: + +```bash +cd /www/wwwroot/new-api +docker-compose up -d +``` + +*** + +## 配置说明 + +### 必要环境变量 + +| 变量名 | 说明 | 是否必填 | +| ------------------- | ------------------ | ------ | +| `SESSION_SECRET` | 会话密钥,多机部署必须一致 | **必填** | +| `CRYPTO_SECRET` | 加密密钥,使用 Redis 时必填 | 条件必填 | +| `SQL_DSN` | 数据库连接字符串(使用外部数据库时) | 可选 | +| `REDIS_CONN_STRING` | Redis 连接字符串 | 可选 | + +### 生成随机密钥 + +```bash +# 生成 SESSION_SECRET +openssl rand -hex 16 + +# 或使用 Linux 命令 +head -c 16 /dev/urandom | xxd -p +``` + +*** + +## 常见问题 + +### Q1:无法访问 3000 端口? + +1. 检查服务器防火墙是否开放 3000 端口 +2. 在宝塔面板 **安全** 中放行 3000 端口 +3. 检查云服务器安全组是否开放端口 + +### Q2:登录后提示会话失效? + +确保设置了 `SESSION_SECRET` 环境变量,且值不为空。 + +### Q3:数据如何持久化? + +使用 Docker 卷映射数据目录: + +```yaml +volumes: + - ./data:/data +``` + +### Q4:如何更新版本? + +```bash +# 拉取最新镜像 +docker pull calciumion/new-api:latest + +# 重启容器 +docker-compose down && docker-compose up -d +``` + +*** + +## 相关链接 + +- [官方文档](https://docs.newapi.pro/zh/docs/installation) +- [环境变量配置](https://docs.newapi.pro/zh/docs/installation/config-maintenance/environment-variables) +- [常见问题](https://docs.newapi.pro/zh/docs/support/faq) +- [GitHub 仓库](https://github.com/QuantumNous/new-api) + +*** + +## 截图示例 + + + +> ⚠️ 注意:密钥为环境变量 `SESSION_SECRET`,请务必设置! - diff --git a/dto/openai_image.go b/dto/openai_image.go index fa09155d..52986fbf 100644 --- a/dto/openai_image.go +++ b/dto/openai_image.go @@ -148,15 +148,14 @@ func (i *ImageRequest) GetTokenCountMeta() *types.TokenCountMeta { } } - // not support token count for dalle - n := uint(1) - if i.N != nil { - n = *i.N - } + // n is NOT included here; it is handled via OtherRatio("n") in + // image_handler.go (default) or channel adaptors (actual count). + // Including n here caused double-counting for channels that also + // set OtherRatio("n") (e.g. Ali/Bailian). return &types.TokenCountMeta{ CombineText: i.Prompt, MaxTokens: 1584, - ImagePriceRatio: sizeRatio * qualityRatio * float64(n), + ImagePriceRatio: sizeRatio * qualityRatio, } } diff --git a/dto/openai_request.go b/dto/openai_request.go index a6fc3f66..76a86662 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -393,7 +393,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { type MessageImageUrl struct { Url string `json:"url"` - Detail string `json:"detail"` + Detail string `json:"detail,omitempty"` MimeType string } diff --git a/dto/openai_response.go b/dto/openai_response.go index ecb2b280..0f5df4a4 100644 --- a/dto/openai_response.go +++ b/dto/openai_response.go @@ -220,10 +220,12 @@ type CompletionsStreamResponse struct { } type Usage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"` + UsageSemantic string `json:"usage_semantic,omitempty"` + UsageSource string `json:"usage_source,omitempty"` PromptTokensDetails InputTokenDetails `json:"prompt_tokens_details"` CompletionTokenDetails OutputTokenDetails `json:"completion_tokens_details"` @@ -251,7 +253,7 @@ type OpenAIVideoResponse struct { type InputTokenDetails struct { CachedTokens int `json:"cached_tokens"` - CachedCreationTokens int `json:"-"` + CachedCreationTokens int `json:"cached_creation_tokens,omitempty"` TextTokens int `json:"text_tokens"` AudioTokens int `json:"audio_tokens"` ImageTokens int `json:"image_tokens"` diff --git a/electron/package-lock.json b/electron/package-lock.json index ab1769dd..4b5931af 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -3948,9 +3948,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/go.mod b/go.mod index 43808976..2efa83f9 100644 --- a/go.mod +++ b/go.mod @@ -46,13 +46,14 @@ require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 github.com/tiktoken-go/tokenizer v0.6.2 + github.com/waffo-com/waffo-go v1.3.1 github.com/yapingcat/gomedia v0.0.0-20240906162731-17feea57090c golang.org/x/crypto v0.45.0 - golang.org/x/image v0.23.0 + golang.org/x/image v0.38.0 golang.org/x/net v0.47.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 golang.org/x/sys v0.38.0 - golang.org/x/text v0.32.0 + golang.org/x/text v0.35.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.4.3 gorm.io/driver/postgres v1.5.2 @@ -121,7 +122,6 @@ require ( github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/samber/go-singleflightx v0.3.2 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect diff --git a/go.sum b/go.sum index eb2be8a4..732b7d4c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Calcium-Ion/go-epay v0.0.4 h1:C96M7WfRLadcIVscWzwLiYs8etI1wrDmtFMuK2zP22A= github.com/Calcium-Ion/go-epay v0.0.4/go.mod h1:cxo/ZOg8ClvE3VAnCmEzbuyAZINSq7kFEN9oHj5WQ2U= github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW515g= @@ -10,34 +12,18 @@ github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0 h1:onfun1RA+Kc github.com/anknown/ahocorasick v0.0.0-20190904063843-d75dbd5169c0/go.mod h1:4yg+jNTYlDEzBjhGS96v+zjyA3lfXlFd5CiTLIkPBLI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6 h1:HblK3eJHq54yET63qPCTJnks3loDse5xRmmqHgHzwoI= github.com/anknown/darts v0.0.0-20151216065714-83ff685239e6/go.mod h1:pbiaLIeYLUbgMY1kwEAdwO6UKD5ZNwdPGQlwokS9fe8= -github.com/aws/aws-sdk-go-v2 v1.37.2 h1:xkW1iMYawzcmYFYEV0UCMxc8gSsjCGEhBXQkdQywVbo= -github.com/aws/aws-sdk-go-v2 v1.37.2/go.mod h1:9Q0OoGQoboYIAJyslFyF1f5K1Ryddop8gqMhWx/n4Wg= github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 h1:6GMWV6CNpA/6fbFHnoAjrv4+LGfyTqZz2LtCHnspgDg= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0/go.mod h1:/mXlTIVG9jbxkqDnr5UQNQxW1HRYxeGklkM9vAFeabg= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= -github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs= -github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo= github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= github.com/aws/aws-sdk-go-v2/credentials v1.19.10/go.mod h1:RnnlFCAlxQCkN2Q379B67USkBMu1PipEEiibzYN5UTE= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2 h1:sPiRHLVUIIQcoVZTNwqQcdtjkqkPopyYmIX0M5ElRf4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.2/go.mod h1:ik86P3sgV+Bk7c1tBFCwI3VxMoSEwl4YkRB9xn1s340= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 h1:F43zk1vemYIqPAwhjTjYIz0irU2EY7sOb/F5eJ3HuyM= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18/go.mod h1:w1jdlZXrGKaJcNoL+Nnrj+k5wlpGXqnNrKoP22HvAug= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2 h1:ZdzDAg075H6stMZtbD2o+PyB933M/f20e9WmCBC17wA= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.2/go.mod h1:eE1IIzXG9sdZCB0pNNpMpsYTLl4YdOQD3njiVN1e/E4= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 h1:xCeWVjj0ki0l3nruoyP2slHsGArMxeiiaoPN5QZH6YQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18/go.mod h1:r/eLGuGCBw6l36ZRWiw6PaZwPXb6YOj+i/7MizNl5/k= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0 h1:JzidOz4Hcn2RbP5fvIS1iAP+DcRv5VJtgixbEYDsI5g= -github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.33.0/go.mod h1:9A4/PJYlWjvjEzzoOLGQjkLt4bYK9fRWi7uz1GSsAcA= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0 h1:TDKR8ACRw7G+GFaQlhoy6biu+8q6ZtSddQCy9avMdMI= github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.0/go.mod h1:XlhOh5Ax/lesqN4aZCUgj9vVJed5VoXYHHFYGAlJEwU= -github.com/aws/smithy-go v1.22.5 h1:P9ATCXPMb2mPjYBgueqJNCA5S9UfktsW0tTxi+a7eqw= -github.com/aws/smithy-go v1.22.5/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -58,7 +44,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -134,12 +119,13 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -188,8 +174,6 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= -github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -247,7 +231,6 @@ github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -264,8 +247,9 @@ github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoG github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/samber/go-singleflightx v0.3.2 h1:jXbUU0fvis8Fdv4HGONboX5WdEZcYLoBEcKiE+ITCyQ= github.com/samber/go-singleflightx v0.3.2/go.mod h1:X2BR+oheHIYc73PvxRMlcASg6KYYTQyUYpdVU7t/ux4= github.com/samber/hot v0.11.0 h1:JhV9hk8SmZIqB0To8OyCzPubvszkuoSXWx/7FCEGO+Q= @@ -322,6 +306,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/waffo-com/waffo-go v1.3.1 h1:NCYD3oQ59DTJj1bwS5T/659LI4h8PuAIW4Qj/w7fKPw= +github.com/waffo-com/waffo-go v1.3.1/go.mod h1:IaXVYq6mmYtrLFFsLxPslNwuIZx0mIadWWjhe+eWb0g= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -332,6 +318,8 @@ github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFi github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -339,18 +327,16 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= -golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= -golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -369,19 +355,14 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/logger/logger.go b/logger/logger.go index 90cf5006..7b0c82de 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -29,6 +29,15 @@ const maxLogCount = 1000000 var logCount int var setupLogLock sync.Mutex var setupLogWorking bool +var currentLogPath string +var currentLogPathMu sync.RWMutex +var currentLogFile *os.File + +func GetCurrentLogPath() string { + currentLogPathMu.RLock() + defer currentLogPathMu.RUnlock() + return currentLogPath +} func SetupLogger() { defer func() { @@ -48,8 +57,19 @@ func SetupLogger() { if err != nil { log.Fatal("failed to open log file") } + currentLogPathMu.Lock() + oldFile := currentLogFile + currentLogPath = logPath + currentLogFile = fd + currentLogPathMu.Unlock() + + common.LogWriterMu.Lock() gin.DefaultWriter = io.MultiWriter(os.Stdout, fd) gin.DefaultErrorWriter = io.MultiWriter(os.Stderr, fd) + if oldFile != nil { + _ = oldFile.Close() + } + common.LogWriterMu.Unlock() } } @@ -75,16 +95,18 @@ func LogDebug(ctx context.Context, msg string, args ...any) { } func logHelper(ctx context.Context, level string, msg string) { - writer := gin.DefaultErrorWriter - if level == loggerINFO { - writer = gin.DefaultWriter - } id := ctx.Value(common.RequestIdKey) if id == nil { id = "SYSTEM" } now := time.Now() + common.LogWriterMu.RLock() + writer := gin.DefaultErrorWriter + if level == loggerINFO { + writer = gin.DefaultWriter + } _, _ = fmt.Fprintf(writer, "[%s] %v | %s | %s \n", level, now.Format("2006/01/02 - 15:04:05"), id, msg) + common.LogWriterMu.RUnlock() logCount++ // we don't need accurate count, so no lock here if logCount > maxLogCount && !setupLogWorking { logCount = 0 diff --git a/middleware/distributor.go b/middleware/distributor.go index db57998c..d6269414 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -101,8 +101,13 @@ func Distribute() func(c *gin.Context) { if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { preferred, err := model.CacheGetChannel(preferredChannelID) - if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { - if usingGroup == "auto" { + if err == nil && preferred != nil { + if preferred.Status != common.ChannelStatusEnabled { + if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { + abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled)) + return + } + } else if usingGroup == "auto" { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) autoGroups := service.GetUserAutoGroup(userGroup) for _, g := range autoGroups { diff --git a/middleware/rate-limit.go b/middleware/rate-limit.go index 10d7d821..d8dd15d9 100644 --- a/middleware/rate-limit.go +++ b/middleware/rate-limit.go @@ -196,7 +196,10 @@ func userRedisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, key } // SearchRateLimit returns a per-user rate limiter for search endpoints. -// 10 requests per 60 seconds per user (by user ID, not IP). +// Configurable via SEARCH_RATE_LIMIT_ENABLE / SEARCH_RATE_LIMIT / SEARCH_RATE_LIMIT_DURATION. func SearchRateLimit() func(c *gin.Context) { + if !common.SearchRateLimitEnable { + return defNext + } return userRateLimitFactory(common.SearchRateLimitNum, common.SearchRateLimitDuration, "SR") } diff --git a/model/log.go b/model/log.go index 2d4782fa..68bc6504 100644 --- a/model/log.go +++ b/model/log.go @@ -58,7 +58,8 @@ func formatUserLogs(logs []*Log, startIdx int) { if otherMap != nil { // Remove admin-only debug fields. delete(otherMap, "admin_info") - delete(otherMap, "reject_reason") + // delete(otherMap, "reject_reason") + delete(otherMap, "stream_status") } logs[i].Other = common.MapToJsonStr(otherMap) logs[i].Id = startIdx + i + 1 diff --git a/model/option.go b/model/option.go index f80b3f7c..e024ca40 100644 --- a/model/option.go +++ b/model/option.go @@ -89,6 +89,22 @@ func InitOptionMap() { common.OptionMap["CreemProducts"] = setting.CreemProducts common.OptionMap["CreemTestMode"] = strconv.FormatBool(setting.CreemTestMode) common.OptionMap["CreemWebhookSecret"] = setting.CreemWebhookSecret + common.OptionMap["WaffoEnabled"] = strconv.FormatBool(setting.WaffoEnabled) + common.OptionMap["WaffoApiKey"] = setting.WaffoApiKey + common.OptionMap["WaffoPrivateKey"] = setting.WaffoPrivateKey + common.OptionMap["WaffoPublicCert"] = setting.WaffoPublicCert + common.OptionMap["WaffoSandboxPublicCert"] = setting.WaffoSandboxPublicCert + common.OptionMap["WaffoSandboxApiKey"] = setting.WaffoSandboxApiKey + common.OptionMap["WaffoSandboxPrivateKey"] = setting.WaffoSandboxPrivateKey + common.OptionMap["WaffoSandbox"] = strconv.FormatBool(setting.WaffoSandbox) + common.OptionMap["WaffoMerchantId"] = setting.WaffoMerchantId + common.OptionMap["WaffoNotifyUrl"] = setting.WaffoNotifyUrl + common.OptionMap["WaffoReturnUrl"] = setting.WaffoReturnUrl + common.OptionMap["WaffoSubscriptionReturnUrl"] = setting.WaffoSubscriptionReturnUrl + common.OptionMap["WaffoCurrency"] = setting.WaffoCurrency + common.OptionMap["WaffoUnitPrice"] = strconv.FormatFloat(setting.WaffoUnitPrice, 'f', -1, 64) + common.OptionMap["WaffoMinTopUp"] = strconv.Itoa(setting.WaffoMinTopUp) + common.OptionMap["WaffoPayMethods"] = setting.WaffoPayMethods2JsonString() common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() @@ -358,6 +374,36 @@ func updateOptionMap(key string, value string) (err error) { setting.CreemTestMode = value == "true" case "CreemWebhookSecret": setting.CreemWebhookSecret = value + case "WaffoEnabled": + setting.WaffoEnabled = value == "true" + case "WaffoApiKey": + setting.WaffoApiKey = value + case "WaffoPrivateKey": + setting.WaffoPrivateKey = value + case "WaffoPublicCert": + setting.WaffoPublicCert = value + case "WaffoSandboxPublicCert": + setting.WaffoSandboxPublicCert = value + case "WaffoSandboxApiKey": + setting.WaffoSandboxApiKey = value + case "WaffoSandboxPrivateKey": + setting.WaffoSandboxPrivateKey = value + case "WaffoSandbox": + setting.WaffoSandbox = value == "true" + case "WaffoMerchantId": + setting.WaffoMerchantId = value + case "WaffoNotifyUrl": + setting.WaffoNotifyUrl = value + case "WaffoReturnUrl": + setting.WaffoReturnUrl = value + case "WaffoSubscriptionReturnUrl": + setting.WaffoSubscriptionReturnUrl = value + case "WaffoCurrency": + setting.WaffoCurrency = value + case "WaffoUnitPrice": + setting.WaffoUnitPrice, _ = strconv.ParseFloat(value, 64) + case "WaffoMinTopUp": + setting.WaffoMinTopUp, _ = strconv.Atoi(value) case "TopupGroupRatio": err = common.UpdateTopupGroupRatioByJSONString(value) case "GitHubClientId": @@ -458,6 +504,10 @@ func updateOptionMap(key string, value string) (err error) { setting.StreamCacheQueueLength, _ = strconv.Atoi(value) case "PayMethods": err = operation_setting.UpdatePayMethodsByJsonString(value) + case "WaffoPayMethods": + // WaffoPayMethods is read directly from OptionMap via setting.GetWaffoPayMethods(). + // The value is already stored in OptionMap at the top of this function (line: common.OptionMap[key] = value). + // No additional in-memory variable to update. } return err } diff --git a/model/topup.go b/model/topup.go index 655d9b77..d8c92bfe 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 { @@ -376,3 +376,62 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string return nil } + +func RechargeWaffo(tradeNo string) (err error) { + if tradeNo == "" { + return errors.New("未提供支付单号") + } + + var quotaToAdd int + topUp := &TopUp{} + + refCol := "`trade_no`" + if common.UsingPostgreSQL { + refCol = `"trade_no"` + } + + err = DB.Transaction(func(tx *gorm.DB) error { + err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error + if err != nil { + return errors.New("充值订单不存在") + } + + if topUp.Status == common.TopUpStatusSuccess { + return nil // 幂等:已成功直接返回 + } + + if topUp.Status != common.TopUpStatusPending { + return errors.New("充值订单状态错误") + } + + dAmount := decimal.NewFromInt(topUp.Amount) + dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) + quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart()) + if quotaToAdd <= 0 { + return errors.New("无效的充值额度") + } + + topUp.CompleteTime = common.GetTimestamp() + topUp.Status = common.TopUpStatusSuccess + if err := tx.Save(topUp).Error; err != nil { + return err + } + + if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil { + return err + } + + return nil + }) + + if err != nil { + common.SysError("waffo topup failed: " + err.Error()) + return errors.New("充值失败,请稍后重试") + } + + if quotaToAdd > 0 { + RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("Waffo充值成功,充值额度: %v,支付金额: %.2f", logger.FormatQuota(quotaToAdd), topUp.Money)) + } + + return nil +} diff --git a/oauth/generic.go b/oauth/generic.go index bc18054d..11bbb9b6 100644 --- a/oauth/generic.go +++ b/oauth/generic.go @@ -208,10 +208,7 @@ func (p *GenericOAuthProvider) GetUserInfo(ctx context.Context, token *OAuthToke } // Set authorization header - tokenType := token.TokenType - if tokenType == "" { - tokenType = "Bearer" - } + tokenType := normalizeAuthorizationTokenType(token.TokenType) req.Header.Set("Authorization", fmt.Sprintf("%s %s", tokenType, token.AccessToken)) req.Header.Set("Accept", "application/json") @@ -320,6 +317,14 @@ func (p *GenericOAuthProvider) GetProviderId() int { return p.config.Id } +func normalizeAuthorizationTokenType(tokenType string) string { + tokenType = strings.TrimSpace(tokenType) + if tokenType == "" || strings.EqualFold(tokenType, "Bearer") { + return "Bearer" + } + return tokenType +} + // IsGenericProvider returns true for generic providers func (p *GenericOAuthProvider) IsGenericProvider() bool { return true diff --git a/relay/audio_handler.go b/relay/audio_handler.go index e16ec29b..7e9f6c48 100644 --- a/relay/audio_handler.go +++ b/relay/audio_handler.go @@ -70,7 +70,7 @@ func AudioHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type if usage.(*dto.Usage).CompletionTokenDetails.AudioTokens > 0 || usage.(*dto.Usage).PromptTokensDetails.AudioTokens > 0 { service.PostAudioConsumeQuota(c, info, usage.(*dto.Usage), "") } else { - postConsumeQuota(c, info, usage.(*dto.Usage)) + service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil) } return nil diff --git a/relay/channel/ali/dto.go b/relay/channel/ali/dto.go index 75be8ff7..ec564f08 100644 --- a/relay/channel/ali/dto.go +++ b/relay/channel/ali/dto.go @@ -171,12 +171,17 @@ type AliImageRequest struct { } type AliImageParameters struct { - Size string `json:"size,omitempty"` - N int `json:"n,omitempty"` - Steps string `json:"steps,omitempty"` - Scale string `json:"scale,omitempty"` - Watermark *bool `json:"watermark,omitempty"` - PromptExtend *bool `json:"prompt_extend,omitempty"` + Size string `json:"size,omitempty"` + N int `json:"n,omitempty"` + Steps string `json:"steps,omitempty"` + Scale string `json:"scale,omitempty"` + Watermark *bool `json:"watermark,omitempty"` + PromptExtend *bool `json:"prompt_extend,omitempty"` + ThinkingMode *bool `json:"thinking_mode,omitempty"` + EnableSequential *bool `json:"enable_sequential,omitempty"` + BboxList any `json:"bbox_list,omitempty"` + ColorPalette any `json:"color_palette,omitempty"` + Seed *int `json:"seed,omitempty"` } func (p *AliImageParameters) PromptExtendValue() bool { diff --git a/relay/channel/ali/image.go b/relay/channel/ali/image.go index 18427d77..79bc2202 100644 --- a/relay/channel/ali/image.go +++ b/relay/channel/ali/image.go @@ -54,7 +54,6 @@ func oaiImage2AliImageRequest(info *relaycommon.RelayInfo, request dto.ImageRequ } } - // 检查n参数 if imageRequest.Parameters.N != 0 { info.PriceData.AddOtherRatio("n", float64(imageRequest.Parameters.N)) } @@ -181,6 +180,7 @@ func oaiFormEdit2AliImageEdit(c *gin.Context, info *relaycommon.RelayInfo, reque }, } imageRequest.Parameters = AliImageParameters{ + N: int(lo.FromPtrOr(request.N, uint(1))), Watermark: request.Watermark, } return &imageRequest, nil @@ -328,7 +328,6 @@ func aliImageHandler(a *Adaptor, c *gin.Context, resp *http.Response, info *rela } imageResponses := responseAli2OpenAIImage(c, aliResponse, originRespBody, info, responseFormat) - // 可能生成多张图片,修正计费数量n if aliResponse.Usage.ImageCount != 0 { info.PriceData.AddOtherRatio("n", float64(aliResponse.Usage.ImageCount)) } else if len(imageResponses.Data) != 0 { diff --git a/relay/channel/ali/image_wan.go b/relay/channel/ali/image_wan.go index c6fcc542..e2f46060 100644 --- a/relay/channel/ali/image_wan.go +++ b/relay/channel/ali/image_wan.go @@ -40,7 +40,8 @@ func oaiFormEdit2WanxImageEdit(c *gin.Context, info *relaycommon.RelayInfo, requ } func isOldWanModel(modelName string) bool { - return strings.Contains(modelName, "wan") && !strings.Contains(modelName, "wan2.6") + return strings.Contains(modelName, "wan") && + !lo.SomeBy([]string{"wan2.6", "wan2.7"}, func(v string) bool { return strings.Contains(modelName, v) }) } func isWanModel(modelName string) bool { diff --git a/relay/channel/baidu/relay-baidu.go b/relay/channel/baidu/relay-baidu.go index cf953a35..a76d7689 100644 --- a/relay/channel/baidu/relay-baidu.go +++ b/relay/channel/baidu/relay-baidu.go @@ -116,12 +116,12 @@ func embeddingResponseBaidu2OpenAI(response *BaiduEmbeddingResponse) *dto.OpenAI func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*types.NewAPIError, *dto.Usage) { usage := &dto.Usage{} - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { var baiduResponse BaiduChatStreamResponse - err := common.Unmarshal([]byte(data), &baiduResponse) - if err != nil { + if err := common.Unmarshal([]byte(data), &baiduResponse); err != nil { common.SysLog("error unmarshalling stream response: " + err.Error()) - return true + sr.Error(err) + return } if baiduResponse.Usage.TotalTokens != 0 { usage.TotalTokens = baiduResponse.Usage.TotalTokens @@ -129,11 +129,10 @@ func baiduStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http. usage.CompletionTokens = baiduResponse.Usage.TotalTokens - baiduResponse.Usage.PromptTokens } response := streamResponseBaidu2OpenAI(&baiduResponse) - err = helper.ObjectData(c, response) - if err != nil { + if err := helper.ObjectData(c, response); err != nil { common.SysLog("error sending stream response: " + err.Error()) + sr.Error(err) } - return true }) service.CloseResponseBodyGracefully(resp) return nil, usage diff --git a/relay/channel/claude/adaptor.go b/relay/channel/claude/adaptor.go index a713c17d..6daf5b6f 100644 --- a/relay/channel/claude/adaptor.go +++ b/relay/channel/claude/adaptor.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "net/url" "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/relay/channel" @@ -41,11 +42,32 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) { } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { - baseURL := fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) - if info.IsClaudeBetaQuery { - baseURL = baseURL + "?beta=true" + requestURL := fmt.Sprintf("%s/v1/messages", info.ChannelBaseUrl) + if !shouldAppendClaudeBetaQuery(info) { + return requestURL, nil } - return baseURL, nil + + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "", err + } + query := parsedURL.Query() + query.Set("beta", "true") + parsedURL.RawQuery = query.Encode() + return parsedURL.String(), nil +} + +func shouldAppendClaudeBetaQuery(info *relaycommon.RelayInfo) bool { + if info == nil { + return false + } + if info.IsClaudeBetaQuery { + return true + } + if info.ChannelOtherSettings.ClaudeBetaQuery { + return true + } + return false } func CommonClaudeHeadersOperation(c *gin.Context, req *http.Header, info *relaycommon.RelayInfo) { diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 0636ecd4..4f507410 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -555,6 +555,35 @@ type ClaudeResponseInfo struct { Done bool } +func cacheCreationTokensForOpenAIUsage(usage *dto.Usage) int { + if usage == nil { + return 0 + } + splitCacheCreationTokens := usage.ClaudeCacheCreation5mTokens + usage.ClaudeCacheCreation1hTokens + if splitCacheCreationTokens == 0 { + return usage.PromptTokensDetails.CachedCreationTokens + } + if usage.PromptTokensDetails.CachedCreationTokens > splitCacheCreationTokens { + return usage.PromptTokensDetails.CachedCreationTokens + } + return splitCacheCreationTokens +} + +func buildOpenAIStyleUsageFromClaudeUsage(usage *dto.Usage) dto.Usage { + if usage == nil { + return dto.Usage{} + } + clone := *usage + cacheCreationTokens := cacheCreationTokensForOpenAIUsage(usage) + totalInputTokens := usage.PromptTokens + usage.PromptTokensDetails.CachedTokens + cacheCreationTokens + clone.PromptTokens = totalInputTokens + clone.InputTokens = totalInputTokens + clone.TotalTokens = totalInputTokens + usage.CompletionTokens + clone.UsageSemantic = "openai" + clone.UsageSource = "anthropic" + return clone +} + func buildMessageDeltaPatchUsage(claudeResponse *dto.ClaudeResponse, claudeInfo *ClaudeResponseInfo) *dto.ClaudeUsage { usage := &dto.ClaudeUsage{} if claudeResponse != nil && claudeResponse.Usage != nil { @@ -643,6 +672,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d // message_start, 获取usage if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil { claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens + claudeInfo.Usage.UsageSemantic = "anthropic" claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens() @@ -661,6 +691,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d } else if claudeResponse.Type == "message_delta" { // 最终的usage获取 if claudeResponse.Usage != nil { + claudeInfo.Usage.UsageSemantic = "anthropic" if claudeResponse.Usage.InputTokens > 0 { // 不叠加,只取最新的 claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens @@ -754,12 +785,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau } claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) } + if claudeInfo.Usage != nil { + claudeInfo.Usage.UsageSemantic = "anthropic" + } if info.RelayFormat == types.RelayFormatClaude { // } else if info.RelayFormat == types.RelayFormatOpenAI { if info.ShouldIncludeUsage { - response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, *claudeInfo.Usage) + openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage) + response := helper.GenerateFinalUsageResponse(claudeInfo.ResponseId, claudeInfo.Created, info.UpstreamModelName, openAIUsage) err := helper.ObjectData(c, response) if err != nil { common.SysLog("send final response failed: " + err.Error()) @@ -778,12 +813,11 @@ func ClaudeStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon. Usage: &dto.Usage{}, } var err *types.NewAPIError - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { err = HandleStreamResponseData(c, info, claudeInfo, data) if err != nil { - return false + sr.Stop(err) } - return true }) if err != nil { return nil, err @@ -810,6 +844,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens + claudeInfo.Usage.UsageSemantic = "anthropic" claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens() @@ -819,7 +854,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud switch info.RelayFormat { case types.RelayFormatOpenAI: openaiResponse := ResponseClaude2OpenAI(&claudeResponse) - openaiResponse.Usage = *claudeInfo.Usage + openaiResponse.Usage = buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage) responseData, err = json.Marshal(openaiResponse) if err != nil { return types.NewError(err, types.ErrorCodeBadResponseBody) diff --git a/relay/channel/claude/relay_claude_test.go b/relay/channel/claude/relay_claude_test.go index e34c861a..4e4004d8 100644 --- a/relay/channel/claude/relay_claude_test.go +++ b/relay/channel/claude/relay_claude_test.go @@ -173,3 +173,85 @@ func TestFormatClaudeResponseInfo_ContentBlockDelta(t *testing.T) { t.Errorf("ResponseText = %q, want %q", claudeInfo.ResponseText.String(), "hello") } } + +func TestBuildOpenAIStyleUsageFromClaudeUsage(t *testing.T) { + usage := &dto.Usage{ + PromptTokens: 100, + CompletionTokens: 20, + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 30, + CachedCreationTokens: 50, + }, + ClaudeCacheCreation5mTokens: 10, + ClaudeCacheCreation1hTokens: 20, + UsageSemantic: "anthropic", + } + + openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage) + + if openAIUsage.PromptTokens != 180 { + t.Fatalf("PromptTokens = %d, want 180", openAIUsage.PromptTokens) + } + if openAIUsage.InputTokens != 180 { + t.Fatalf("InputTokens = %d, want 180", openAIUsage.InputTokens) + } + if openAIUsage.TotalTokens != 200 { + t.Fatalf("TotalTokens = %d, want 200", openAIUsage.TotalTokens) + } + if openAIUsage.UsageSemantic != "openai" { + t.Fatalf("UsageSemantic = %s, want openai", openAIUsage.UsageSemantic) + } + if openAIUsage.UsageSource != "anthropic" { + t.Fatalf("UsageSource = %s, want anthropic", openAIUsage.UsageSource) + } +} + +func TestBuildOpenAIStyleUsageFromClaudeUsagePreservesCacheCreationRemainder(t *testing.T) { + tests := []struct { + name string + cachedCreationTokens int + cacheCreationTokens5m int + cacheCreationTokens1h int + expectedTotalInputToken int + }{ + { + name: "prefers aggregate when it includes remainder", + cachedCreationTokens: 50, + cacheCreationTokens5m: 10, + cacheCreationTokens1h: 20, + expectedTotalInputToken: 180, + }, + { + name: "falls back to split tokens when aggregate missing", + cachedCreationTokens: 0, + cacheCreationTokens5m: 10, + cacheCreationTokens1h: 20, + expectedTotalInputToken: 160, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usage := &dto.Usage{ + PromptTokens: 100, + CompletionTokens: 20, + PromptTokensDetails: dto.InputTokenDetails{ + CachedTokens: 30, + CachedCreationTokens: tt.cachedCreationTokens, + }, + ClaudeCacheCreation5mTokens: tt.cacheCreationTokens5m, + ClaudeCacheCreation1hTokens: tt.cacheCreationTokens1h, + UsageSemantic: "anthropic", + } + + openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(usage) + + if openAIUsage.PromptTokens != tt.expectedTotalInputToken { + t.Fatalf("PromptTokens = %d, want %d", openAIUsage.PromptTokens, tt.expectedTotalInputToken) + } + if openAIUsage.InputTokens != tt.expectedTotalInputToken { + t.Fatalf("InputTokens = %d, want %d", openAIUsage.InputTokens, tt.expectedTotalInputToken) + } + }) + } +} diff --git a/relay/channel/dify/relay-dify.go b/relay/channel/dify/relay-dify.go index bec135b8..80094f88 100644 --- a/relay/channel/dify/relay-dify.go +++ b/relay/channel/dify/relay-dify.go @@ -223,33 +223,32 @@ func difyStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R usage := &dto.Usage{} var nodeToken int helper.SetEventStreamHeaders(c) - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { var difyResponse DifyChunkChatCompletionResponse - err := json.Unmarshal([]byte(data), &difyResponse) - if err != nil { + if err := json.Unmarshal([]byte(data), &difyResponse); err != nil { common.SysLog("error unmarshalling stream response: " + err.Error()) - return true + sr.Error(err) + return } - var openaiResponse dto.ChatCompletionsStreamResponse if difyResponse.Event == "message_end" { usage = &difyResponse.MetaData.Usage - return false + sr.Done() + return } else if difyResponse.Event == "error" { - return false - } else { - openaiResponse = *streamResponseDify2OpenAI(difyResponse) - if len(openaiResponse.Choices) != 0 { - responseText += openaiResponse.Choices[0].Delta.GetContentString() - if openaiResponse.Choices[0].Delta.ReasoningContent != nil { - nodeToken += 1 - } + sr.Stop(fmt.Errorf("dify error event")) + return + } + openaiResponse := *streamResponseDify2OpenAI(difyResponse) + if len(openaiResponse.Choices) != 0 { + responseText += openaiResponse.Choices[0].Delta.GetContentString() + if openaiResponse.Choices[0].Delta.ReasoningContent != nil { + nodeToken += 1 } } - err = helper.ObjectData(c, openaiResponse) - if err != nil { + if err := helper.ObjectData(c, openaiResponse); err != nil { common.SysLog(err.Error()) + sr.Error(err) } - return true }) helper.Done(c) if usage.TotalTokens == 0 { diff --git a/relay/channel/gemini/relay-gemini.go b/relay/channel/gemini/relay-gemini.go index dea4c7ac..32f9dcc7 100644 --- a/relay/channel/gemini/relay-gemini.go +++ b/relay/channel/gemini/relay-gemini.go @@ -1305,12 +1305,11 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http var imageCount int responseText := strings.Builder{} - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { var geminiResponse dto.GeminiChatResponse - err := common.UnmarshalJsonStr(data, &geminiResponse) - if err != nil { - logger.LogError(c, "error unmarshalling stream response: "+err.Error()) - return false + if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil { + sr.Stop(fmt.Errorf("unmarshal: %w", err)) + return } if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil { @@ -1335,7 +1334,9 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http *usage = mappedUsage } - return callback(data, &geminiResponse) + if !callback(data, &geminiResponse) { + sr.Stop(fmt.Errorf("gemini callback stopped")) + } }) if imageCount != 0 { diff --git a/relay/channel/openai/audio.go b/relay/channel/openai/audio.go index 877f5bb1..3bab3c1a 100644 --- a/relay/channel/openai/audio.go +++ b/relay/channel/openai/audio.go @@ -35,21 +35,21 @@ func OpenaiTTSHandler(c *gin.Context, resp *http.Response, info *relaycommon.Rel c.Writer.WriteHeader(resp.StatusCode) if info.IsStream { - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { if service.SundaySearch(data, "usage") { var simpleResponse dto.SimpleResponse - err := common.Unmarshal([]byte(data), &simpleResponse) - if err != nil { + if err := common.Unmarshal([]byte(data), &simpleResponse); err != nil { logger.LogError(c, err.Error()) - } - if simpleResponse.Usage.TotalTokens != 0 { + sr.Error(err) + } else if simpleResponse.Usage.TotalTokens != 0 { usage.PromptTokens = simpleResponse.Usage.InputTokens usage.CompletionTokens = simpleResponse.OutputTokens usage.TotalTokens = simpleResponse.TotalTokens } } - _ = helper.StringData(c, data) - return true + if err := helper.StringData(c, data); err != nil { + sr.Error(err) + } }) } else { common.SetContextKey(c, constant.ContextKeyLocalCountTokens, true) diff --git a/relay/channel/openai/chat_via_responses.go b/relay/channel/openai/chat_via_responses.go index 1aa06473..5e8ec173 100644 --- a/relay/channel/openai/chat_via_responses.go +++ b/relay/channel/openai/chat_via_responses.go @@ -296,15 +296,17 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo return true } - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { if streamErr != nil { - return false + sr.Stop(streamErr) + return } var streamResp dto.ResponsesStreamResponse if err := common.UnmarshalJsonStr(data, &streamResp); err != nil { logger.LogError(c, "failed to unmarshal responses stream event: "+err.Error()) - return true + sr.Error(err) + return } switch streamResp.Type { @@ -320,14 +322,16 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo //case "response.reasoning_text.delta": //if !sendReasoningDelta(streamResp.Delta) { - // return false + // sr.Stop(streamErr) + // return //} //case "response.reasoning_text.done": case "response.reasoning_summary_text.delta": if !sendReasoningSummaryDelta(streamResp.Delta) { - return false + sr.Stop(streamErr) + return } case "response.reasoning_summary_text.done": @@ -349,12 +353,14 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo // delta := stringDeltaFromPrefix(prev, next) // reasoningSummaryTextByKey[key] = next // if !sendReasoningSummaryDelta(delta) { - // return false + // sr.Stop(streamErr) + // return // } case "response.output_text.delta": if !sendStartIfNeeded() { - return false + sr.Stop(streamErr) + return } if streamResp.Delta != "" { @@ -376,7 +382,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo }, } if !sendChatChunk(chunk) { - return false + sr.Stop(streamErr) + return } } @@ -414,7 +421,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } if !sendToolCallDelta(callID, name, argsDelta) { - return false + sr.Stop(streamErr) + return } case "response.function_call_arguments.delta": @@ -428,7 +436,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } toolCallArgsByID[callID] += streamResp.Delta if !sendToolCallDelta(callID, "", streamResp.Delta) { - return false + sr.Stop(streamErr) + return } case "response.function_call_arguments.done": @@ -467,7 +476,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } if !sendStartIfNeeded() { - return false + sr.Stop(streamErr) + return } if !sentStop { if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil { @@ -479,7 +489,8 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo } stop := helper.GenerateStopResponse(responseId, createAt, model, finishReason) if !sendChatChunk(stop) { - return false + sr.Stop(streamErr) + return } sentStop = true } @@ -488,16 +499,16 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo if streamResp.Response != nil { if oaiErr := streamResp.Response.GetOpenAIError(); oaiErr != nil && oaiErr.Type != "" { streamErr = types.WithOpenAIError(*oaiErr, http.StatusInternalServerError) - return false + sr.Stop(streamErr) + return } } streamErr = types.NewOpenAIError(fmt.Errorf("responses stream error: %s", streamResp.Type), types.ErrorCodeBadResponse, http.StatusInternalServerError) - return false + sr.Stop(streamErr) + return default: } - - return true }) if streamErr != nil { diff --git a/relay/channel/openai/relay-openai.go b/relay/channel/openai/relay-openai.go index a4de1611..d33c5555 100644 --- a/relay/channel/openai/relay-openai.go +++ b/relay/channel/openai/relay-openai.go @@ -126,11 +126,11 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re // 检查是否为音频模型 isAudioModel := strings.Contains(strings.ToLower(model), "audio") - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { if lastStreamData != "" { - err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent) - if err != nil { + if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil { common.SysLog("error handling stream format: " + err.Error()) + sr.Error(err) } } if len(data) > 0 { @@ -142,7 +142,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re lastStreamData = data streamItems = append(streamItems, data) } - return true }) // 对音频模型,从倒数第二个stream data中提取usage信息 @@ -627,6 +626,12 @@ func applyUsagePostProcessing(info *relaycommon.RelayInfo, usage *dto.Usage, res usage.PromptTokensDetails.CachedTokens = usage.PromptCacheHitTokens } } + case constant.ChannelTypeOpenAI: + if usage.PromptTokensDetails.CachedTokens == 0 { + if cachedTokens, ok := extractLlamaCachedTokensFromBody(responseBody); ok { + usage.PromptTokensDetails.CachedTokens = cachedTokens + } + } } } @@ -689,3 +694,25 @@ func extractMoonshotCachedTokensFromBody(body []byte) (int, bool) { return 0, false } + +// extractLlamaCachedTokensFromBody 从llama.cpp的非标准位置提取cache_n +func extractLlamaCachedTokensFromBody(body []byte) (int, bool) { + if len(body) == 0 { + return 0, false + } + + var payload struct { + Timings struct { + CachedTokens *int `json:"cache_n"` + } `json:"timings"` + } + + if err := common.Unmarshal(body, &payload); err != nil { + return 0, false + } + + if payload.Timings.CachedTokens == nil { + return 0, false + } + return *payload.Timings.CachedTokens, true +} diff --git a/relay/channel/openai/relay_responses.go b/relay/channel/openai/relay_responses.go index b92c8c72..2665b8d0 100644 --- a/relay/channel/openai/relay_responses.go +++ b/relay/channel/openai/relay_responses.go @@ -79,55 +79,55 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp var usage = &dto.Usage{} var responseTextBuilder strings.Builder - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { // 检查当前数据是否包含 completed 状态和 usage 信息 var streamResponse dto.ResponsesStreamResponse - if err := common.UnmarshalJsonStr(data, &streamResponse); err == nil { - sendResponsesStreamData(c, streamResponse, data) - switch streamResponse.Type { - case "response.completed": - if streamResponse.Response != nil { - if streamResponse.Response.Usage != nil { - if streamResponse.Response.Usage.InputTokens != 0 { - usage.PromptTokens = streamResponse.Response.Usage.InputTokens - } - if streamResponse.Response.Usage.OutputTokens != 0 { - usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens - } - if streamResponse.Response.Usage.TotalTokens != 0 { - usage.TotalTokens = streamResponse.Response.Usage.TotalTokens - } - if streamResponse.Response.Usage.InputTokensDetails != nil { - usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens - } + if err := common.UnmarshalJsonStr(data, &streamResponse); err != nil { + logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) + sr.Error(err) + return + } + sendResponsesStreamData(c, streamResponse, data) + switch streamResponse.Type { + case "response.completed": + if streamResponse.Response != nil { + if streamResponse.Response.Usage != nil { + if streamResponse.Response.Usage.InputTokens != 0 { + usage.PromptTokens = streamResponse.Response.Usage.InputTokens } - if streamResponse.Response.HasImageGenerationCall() { - c.Set("image_generation_call", true) - c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) - c.Set("image_generation_call_size", streamResponse.Response.GetSize()) + if streamResponse.Response.Usage.OutputTokens != 0 { + usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens + } + if streamResponse.Response.Usage.TotalTokens != 0 { + usage.TotalTokens = streamResponse.Response.Usage.TotalTokens + } + if streamResponse.Response.Usage.InputTokensDetails != nil { + usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens } } - case "response.output_text.delta": - // 处理输出文本 - responseTextBuilder.WriteString(streamResponse.Delta) - case dto.ResponsesOutputTypeItemDone: - // 函数调用处理 - if streamResponse.Item != nil { - switch streamResponse.Item.Type { - case dto.BuildInCallWebSearchCall: - if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { - if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { - webSearchTool.CallCount++ - } + if streamResponse.Response.HasImageGenerationCall() { + c.Set("image_generation_call", true) + c.Set("image_generation_call_quality", streamResponse.Response.GetQuality()) + c.Set("image_generation_call_size", streamResponse.Response.GetSize()) + } + } + case "response.output_text.delta": + // 处理输出文本 + responseTextBuilder.WriteString(streamResponse.Delta) + case dto.ResponsesOutputTypeItemDone: + // 函数调用处理 + if streamResponse.Item != nil { + switch streamResponse.Item.Type { + case dto.BuildInCallWebSearchCall: + if info != nil && info.ResponsesUsageInfo != nil && info.ResponsesUsageInfo.BuiltInTools != nil { + if webSearchTool, exists := info.ResponsesUsageInfo.BuiltInTools[dto.BuildInToolWebSearchPreview]; exists && webSearchTool != nil { + webSearchTool.CallCount++ } } } } - } else { - logger.LogError(c, "failed to unmarshal stream response: "+err.Error()) } - return true }) if usage.CompletionTokens == 0 { diff --git a/relay/channel/task/taskcommon/helpers.go b/relay/channel/task/taskcommon/helpers.go index 27d6612d..7d1820c9 100644 --- a/relay/channel/task/taskcommon/helpers.go +++ b/relay/channel/task/taskcommon/helpers.go @@ -17,6 +17,8 @@ func UnmarshalMetadata(metadata map[string]any, target any) error { if metadata == nil { return nil } + // Prevent metadata from overriding model fields to avoid billing bypass. + delete(metadata, "model") metaBytes, err := common.Marshal(metadata) if err != nil { return fmt.Errorf("marshal metadata failed: %w", err) diff --git a/relay/channel/xai/adaptor.go b/relay/channel/xai/adaptor.go index e172bccf..c73bd8cf 100644 --- a/relay/channel/xai/adaptor.go +++ b/relay/channel/xai/adaptor.go @@ -76,7 +76,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn if strings.HasPrefix(request.Model, "grok-3-mini") { if lo.FromPtrOr(request.MaxCompletionTokens, uint(0)) == 0 && lo.FromPtrOr(request.MaxTokens, uint(0)) != 0 { request.MaxCompletionTokens = request.MaxTokens - request.MaxTokens = lo.ToPtr(uint(0)) + request.MaxTokens = nil } if strings.HasSuffix(request.Model, "-high") { request.ReasoningEffort = "high" diff --git a/relay/channel/xai/text.go b/relay/channel/xai/text.go index c72ea849..f9a8ee2e 100644 --- a/relay/channel/xai/text.go +++ b/relay/channel/xai/text.go @@ -43,12 +43,12 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re helper.SetEventStreamHeaders(c) - helper.StreamScannerHandler(c, resp, info, func(data string) bool { + helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) { var xAIResp *dto.ChatCompletionsStreamResponse - err := common.UnmarshalJsonStr(data, &xAIResp) - if err != nil { + if err := common.UnmarshalJsonStr(data, &xAIResp); err != nil { common.SysLog("error unmarshalling stream response: " + err.Error()) - return true + sr.Error(err) + return } // 把 xAI 的usage转换为 OpenAI 的usage @@ -61,11 +61,10 @@ func xAIStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re openaiResponse := streamResponseXAI2OpenAI(xAIResp, usage) _ = openai.ProcessStreamResponse(*openaiResponse, &responseTextBuilder, &toolCount) - err = helper.ObjectData(c, openaiResponse) - if err != nil { + if err := helper.ObjectData(c, openaiResponse); err != nil { common.SysLog(err.Error()) + sr.Error(err) } - return true }) if !containStreamUsage { diff --git a/relay/claude_handler.go b/relay/claude_handler.go index dbdb3663..dc4c93f8 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -122,7 +122,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return newApiErr } - service.PostClaudeConsumeQuota(c, info, usage) + service.PostTextConsumeQuota(c, info, usage, nil) return nil } @@ -190,6 +190,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return newAPIError } - service.PostClaudeConsumeQuota(c, info, usage.(*dto.Usage)) + service.PostTextConsumeQuota(c, info, usage.(*dto.Usage), nil) return nil } diff --git a/relay/common/override.go b/relay/common/override.go index 8bfdcd74..af0b4361 100644 --- a/relay/common/override.go +++ b/relay/common/override.go @@ -21,10 +21,23 @@ var negativeIndexRegexp = regexp.MustCompile(`\.(-\d+)`) const ( paramOverrideContextRequestHeaders = "request_headers" paramOverrideContextHeaderOverride = "header_override" + paramOverrideContextAuditRecorder = "__param_override_audit_recorder" ) var errSourceHeaderNotFound = errors.New("source header does not exist") +var paramOverrideKeyAuditPaths = map[string]struct{}{ + "model": {}, + "original_model": {}, + "upstream_model": {}, + "service_tier": {}, + "inference_geo": {}, +} + +type paramOverrideAuditRecorder struct { + lines []string +} + type ConditionOperation struct { Path string `json:"path"` // JSON路径 Mode string `json:"mode"` // full, prefix, suffix, contains, gt, gte, lt, lte @@ -118,6 +131,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c if len(paramOverride) == 0 { return jsonData, nil } + auditRecorder := getParamOverrideAuditRecorder(conditionContext) // 尝试断言为操作格式 if operations, ok := tryParseOperations(paramOverride); ok { @@ -125,7 +139,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c workingJSON := jsonData var err error if len(legacyOverride) > 0 { - workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride) + workingJSON, err = applyOperationsLegacy(workingJSON, legacyOverride, auditRecorder) if err != nil { return nil, err } @@ -137,7 +151,7 @@ func ApplyParamOverride(jsonData []byte, paramOverride map[string]interface{}, c } // 直接使用旧方法 - return applyOperationsLegacy(jsonData, paramOverride) + return applyOperationsLegacy(jsonData, paramOverride, auditRecorder) } func buildLegacyParamOverride(paramOverride map[string]interface{}) map[string]interface{} { @@ -161,14 +175,200 @@ func ApplyParamOverrideWithRelayInfo(jsonData []byte, info *RelayInfo) ([]byte, } overrideCtx := BuildParamOverrideContext(info) + var recorder *paramOverrideAuditRecorder + if shouldEnableParamOverrideAudit(paramOverride) { + recorder = ¶mOverrideAuditRecorder{} + overrideCtx[paramOverrideContextAuditRecorder] = recorder + } result, err := ApplyParamOverride(jsonData, paramOverride, overrideCtx) if err != nil { return nil, err } syncRuntimeHeaderOverrideFromContext(info, overrideCtx) + if info != nil { + if recorder != nil { + info.ParamOverrideAudit = recorder.lines + } else { + info.ParamOverrideAudit = nil + } + } return result, nil } +func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool { + if common.DebugEnabled { + return true + } + if len(paramOverride) == 0 { + return false + } + if operations, ok := tryParseOperations(paramOverride); ok { + for _, operation := range operations { + if shouldAuditParamPath(strings.TrimSpace(operation.Path)) || + shouldAuditParamPath(strings.TrimSpace(operation.To)) { + return true + } + } + for key := range buildLegacyParamOverride(paramOverride) { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false + } + for key := range paramOverride { + if shouldAuditParamPath(strings.TrimSpace(key)) { + return true + } + } + return false +} + +func getParamOverrideAuditRecorder(context map[string]interface{}) *paramOverrideAuditRecorder { + if context == nil { + return nil + } + recorder, _ := context[paramOverrideContextAuditRecorder].(*paramOverrideAuditRecorder) + return recorder +} + +func (r *paramOverrideAuditRecorder) recordOperation(mode, path, from, to string, value interface{}) { + if r == nil { + return + } + line := buildParamOverrideAuditLine(mode, path, from, to, value) + if line == "" { + return + } + if lo.Contains(r.lines, line) { + return + } + r.lines = append(r.lines, line) +} + +func shouldAuditParamPath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if common.DebugEnabled { + return true + } + _, ok := paramOverrideKeyAuditPaths[path] + return ok +} + +func shouldAuditOperation(mode, path, from, to string) bool { + if common.DebugEnabled { + return true + } + for _, candidate := range []string{path, to} { + if shouldAuditParamPath(candidate) { + return true + } + } + return false +} + +func formatParamOverrideAuditValue(value interface{}) string { + switch typed := value.(type) { + case nil: + return "