Compare commits

..

25 Commits

Author SHA1 Message Date
t0ng7u
a4617097fb fix: Improve subscription payment handling and card layout consistency
Unify Epay subscription response format with top-up flow, and harden frontend error handling to avoid object-to-string issues. Refine subscription plan cards layout to be wider, left-aligned, and visually consistent across breakpoints.
2026-02-05 01:34:04 +08:00
Calcium-Ion
ca91d6992e Merge pull request #2635 from feitianbubu/pr/1a2a0dbd92384bfe886b93606003f6753fcb4e9d
feat: task log show username
2026-02-04 23:39:41 +08:00
Calcium-Ion
65b2ca4176 Merge pull request #2835 from QuantumNous/feat/performance-monitoring
feat(performance): implement system performance monitoring
2026-02-04 21:34:44 +08:00
CaIon
7a4fc68bcc feat(performance): implement system performance monitoring with configurable thresholds 2026-02-04 21:26:07 +08:00
CaIon
7cfed0df8e refactor(gemini): remove GeminiVisionMaxImageNum constant and related image count logic 2026-02-04 19:10:06 +08:00
Calcium-Ion
564f407a6b Merge pull request #2832 from QuantumNous/revert-2759-fix-group-colors
Revert "fix(ui): use distinct color palette for group tags"
2026-02-04 19:06:35 +08:00
Seefs
117c9a8699 Revert "fix(ui): use distinct color palette for group tags" 2026-02-04 19:05:56 +08:00
CaIon
e2ebd42a8c feat(cache): enhance disk cache management with concurrency control and cleanup optimizations 2026-02-04 18:23:17 +08:00
CaIon
9ef7740fe7 feat(file): unify file handling with a new FileSource abstraction for URL and base64 data 2026-02-04 18:23:17 +08:00
Calcium-Ion
89b2782675 Merge pull request #2825 from seefs001/feature/request-id-log-column
feat: log search field request_id && conversion_path display
2026-02-04 15:13:06 +08:00
Seefs
e7d5c61d53 Merge pull request #2819 from feitianbubu/pr/65623826f5d9578addbb73be4739dc54f41acd8b
feat: add useTimeSeconds in error log
2026-02-04 15:00:46 +08:00
Seefs
bb54ed91dc feat: capture request_id, filter by request_id, show request_conversion 2026-02-04 14:47:42 +08:00
Seefs
d8d1f141c2 The conversion path is displayed to users by default. 2026-02-04 02:17:30 +08:00
Seefs
dd467ed592 feat: log search field request_id 2026-02-04 02:12:18 +08:00
CaIon
f1e6c1bf77 feat(subscription): implement SQLite support for SubscriptionPlan table creation and migration
#2823
2026-02-04 01:42:55 +08:00
CaIon
1ee80930d4 fix(workflow): enhance tag resolution and error handling in Docker image build 2026-02-04 00:13:47 +08:00
CaIon
35a4c586aa feat(workflow): add manual trigger and tag input for Docker image builds
- Introduce a `workflow_dispatch` event to allow manual triggering of the Docker image build workflow.
- Add an input parameter for specifying the tag name, enhancing flexibility in build processes.
- Update tag resolution logic to prioritize the input tag when provided, ensuring accurate versioning during builds.
2026-02-04 00:09:02 +08:00
CaIon
85b5d0100a feat(epay): enhance parameter parsing for notify and return handlers
- Update EpayNotify and SubscriptionEpayNotify functions to handle both POST and GET requests for parameter parsing.
- Improve error handling by logging failures and returning appropriate responses when parameters are missing or parsing fails.
- Ensure consistent behavior across both notify and return routes for better reliability in payment processing.
2026-02-03 23:37:12 +08:00
CaIon
6a9522ac5b feat(subscription): validate price amount and migrate database column type
- Add validation to ensure subscription plan price amount is non-negative and does not exceed 9999.
- Migrate the price_amount column from float/double to decimal(10,6) in the database for improved precision.
- Update SubscriptionPlan model to reflect the new decimal type for price_amount.
2026-02-03 18:58:28 +08:00
feitianbubu
3b76b770b9 feat: add useTimeSeconds in error log 2026-02-03 18:42:27 +08:00
CaIon
59c076978e feat: add license section to README files in multiple languages 2026-02-03 18:34:50 +08:00
Calcium-Ion
5889856a55 Merge pull request #2814 from thirking/fix/remove-unescape-function
fix: 移除不必要的 unescapeMapOrSlice 调用,修复 Windows 路径转义问题
2026-02-03 17:42:09 +08:00
同語
9da3412fde feat: add subscription billing system (#2808)
* ci: create docker automation

*  feat: add subscription billing system with admin management and user purchase flow

Implement a new subscription-based billing model alongside existing metered/per-request billing:

Backend:
- Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.)
- Implement CRUD APIs for subscription plan management (admin only)
- Add user subscription queries with support for multiple active/expired subscriptions
- Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases
- Implement pre-consume and post-consume billing logic for subscription quota tracking
- Add billing preference settings (subscription_first, wallet_first, etc.)
- Enhance usage logs with subscription deduction details

Frontend - Admin:
- Add subscription management page with table view and drawer-based edit form
- Match UI/UX style with existing admin pages (redemption codes, users)
- Support enabling/disabling plans, configuring payment IDs, and model quotas
- Add user subscription binding modal in user management

Frontend - Wallet:
- Add subscription plans card with current subscription status display
- Show all subscriptions (active and expired) with remaining days/usage percentage
- Display purchasable plans with pricing cards following SaaS best practices
- Extract purchase modal to separate component matching payment confirm modal style
- Add skeleton loading states with active animation
- Implement billing preference selector in card header
- Handle payment gateway availability based on admin configuration

Frontend - Usage Logs:
- Display subscription deduction details in log entries
- Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining)
- Add subscription deduction tag for subscription-covered requests

*  feat(admin): add user subscription management and refine UI/pagination

Add admin APIs to list/create/invalidate/delete user subscriptions
Add model helpers to fetch all user subscriptions (incl. expired) and support cancel/hard-delete
Wire new admin routes for user subscription operations
Replace “Bind subscription plan” entry with a dedicated User Subscriptions SideSheet in Users table
Use CardTable with responsive layout and working client-side pagination inside the SideSheet
Improve subscription purchase modal empty-gateway state with a Banner notice

*  feat(admin): streamline subscription plan benefits editor with bulk actions

Restore the avatar/icon header for the “Model Benefits” section
Replace scattered controls with a compact toolbar-style workflow
Support multi-select add with a default quota for new items
Add row selection with bulk apply-to-selected / apply-to-all quota updates
Enable delete-selected to manage benefits faster and reduce mistakes

*  fix(subscription): finalize payments, log billing, and clean up dead code

Complete subscription orders by creating a matching top-up record and writing billing logs
Add Epay return handler to verify and finalize browser callbacks
Require Stripe/Creem webhook configuration before starting subscription payments
Show subscription purchases in topup history with clearer labels/methods
Remove unused subscription helper, legacy Creem webhook struct, and unused topup fields
Simplify subscription self API payload to active/all lists only

* 🎨 style: format all code with gofmt and lint:fix

Apply consistent code formatting across the entire codebase using
gofmt and lint:fix tools. This ensures adherence to Go community
standards and improves code readability and maintainability.

Changes include:
- Run gofmt on all .go files to standardize formatting
- Apply lint:fix to automatically resolve linting issues
- Fix code style inconsistencies and formatting violations

No functional changes were made in this commit.

*  feat(subscription): add quota reset periods and admin configuration

- Add reset period fields on subscription plans and user items
- Apply automatic quota resets during pre-consume based on plan schedule
- Expose reset-period configuration in the admin plan editor
- Display reset cadence in subscription cards and purchase modal
- Validate custom reset seconds on plan create/update

*  feat(subscription): harden subscription billing with resets, idempotency, and production-grade stability

Add plan-level quota reset periods and display/reset cadence in admin/UI
Enforce natural reset alignment with background reset task and cleanup job
Make subscription pre-consume/refund idempotent with request-scoped records and retries
Use database time for consistent resets across multi-instance deployments
Harden payment callbacks with locking and idempotent order completion
Record subscription purchases in topup history and billing logs
Optimize subscription queries and add critical composite indexes

*  feat(subscription): cache plan lookups and stabilize pre-consume

Introduce hybrid caches for subscription plans, items, and plan info with explicit
invalidation on admin updates. Streamline pre-consume transactions to reduce
redundant queries while preserving idempotency and reset logic.

* 🐛 fix(subscription): avoid pre-consume lookup noise

Use a RowsAffected check for the idempotency lookup so missing records
no longer surface as "record not found" errors while preserving behavior.

* 🔧 ci: Change workflow trigger to sub branch

Update the Docker image workflow to run on pushes to the sub branch instead of main.

* 💸 chore: Align subscription pricing display with global currency settings
Unify subscription price rendering to use the site-wide currency symbol/rate on the wallet and admin views.
Make subscription plan currency read-only in the editor and force USD on create/update to avoid drift.
Use global currency display type when creating Creem checkout payloads.

* 🔧 chore: Unify subscription plan status toggle with PATCH endpoint

Replace separate enable/disable flows with a single PATCH API that updates the enabled flag.
Update frontend hooks and table actions to call the unified endpoint and keep UI behavior consistent.
Introduce a minimal admin controller handler and route for the status update.

*  feat: Add subscription limits and UI tags consistency

Add per-plan purchase limits with backend enforcement and UI disable states.
Expose limit configuration in admin plan editor and show limits in plan tables/cards.
Refine subscription UI tags with unified badge style and streamlined “My Subscriptions” layout.

* 🎨 style: tag color to white

* 🚀 refactor: Simplify subscription quota to total amount model

Remove per-model subscription items and switch to a single total quota per plan and user subscription. Update billing, reset, and logging flows to operate on total quota, and refactor admin/user UI to configure and display total quota consistently.

* 🚀 chore: Remove duplicate subscription usage percentage display

Keep the usage percentage shown only in the total quota line to avoid redundant “已用 0%” text while preserving remaining days in the summary.

*  feat: Add subscription upgrade group with auto downgrade

*  feat: Update subscription purchase modal display

Show total quota as currency with tooltip for raw quota, hide reset cycle when never, and display upgrade group when configured to match card display rules.

*  feat: Extract quota conversion helpers to shared utils

Move quota display/conversion helpers into web/src/helpers/quota.js and update the subscription plan editor to import and use the shared utilities instead of inline functions.

*  chore: Add upgrade group guidance in subscription editor

Add explanatory helper text under the upgrade group field to clarify automatic group upgrades, rollback conditions, and the expected delay before downgrading takes effect.

* 🔧 chore: remove unused Creem settings state

Drop the unused originInputs state and redundant updates to keep the Creem
settings form state minimal and easier to maintain.

* 🚀 chore: Remove useless action

*  Add full i18n coverage for subscription-related UI across locales

*  feat: harden subscription billing and improve UI consistency

Improve subscription payment safety and data integrity by handling user/URL lookup failures, fixing Stripe subscription mode, persisting quota reset fields, and correcting subscription delta accounting and DB timestamp casting. Refine the UI with stricter custom duration validation, accurate currency rounding, conditional Epay labeling, rollback on preference update failure, and shared subscription formatting helpers plus clearer component naming.

* 🔧 fix: make epay webhook and return flow subscription-aware

Ensure Epay webhook acknowledges success only after order completion, returning fail on processing errors to allow retries. Redirect subscription payment returns to the subscription page instead of top-up for correct user flow.

* 🚦 fix: guard epay return success on order completion
Redirect subscription return flow to failure when order completion fails, preventing false success states after payment verification.

* 🔧 fix: normalize epay error handling and webhook retries

Standardize SubscriptionRequestEpay error responses via ApiErrorMsg for a consistent schema.
Return "fail" on non-success trade statuses in the epay webhook to preserve retry behavior.

* 🧾 fix: persist epay orders before purchase

Create the subscription order before initiating epay payment and expire it if the provider call fails, preventing orphaned transactions and improving reconciliation.

* 🔧 fix: harden epay callbacks and billing fallbacks

Use POST and form parsing for epay notify/return routes, persist epay orders before provider calls with expiry on failure, and ensure notify handlers retry correctly.
Restrict subscription-first fallback to insufficient-subscription errors and log refund failures after retries to avoid silent quota drift.

* 🔧 fix: harden billing flow and sidebar settings

Add missing strings import for subscription fallback checks, log failed subscription refunds after retries, and extend sidebar module settings with a subscription management toggle plus translations.

* 🛡️ fix: fail fast on epay form parse errors

Handle ParseForm errors in epay notify/return handlers by returning fail or redirecting to failure, avoiding unsafe fallback to query parameters.

*  fix: refine Japanese subscription status labels

Adjust Japanese UI wording for active-count labels to read more naturally and consistently.

*  fix: standardize epay success response schema

Return subscription epay pay success responses via ApiSuccess to include the consistent success field and align with error schema.
2026-02-03 17:40:43 +08:00
thirking
8d67c571e4 fix: remove unnecessary unescapeMapOrSlice call in Gemini relay
The JSON serialization/deserialization already handles escape characters
correctly, so the unescapeMapOrSlice function is redundant.
2026-02-03 11:47:45 +08:00
feitianbubu
34ac066f36 feat: task log show username 2026-01-10 23:23:29 +08:00
63 changed files with 2085 additions and 544 deletions

View File

@@ -4,6 +4,12 @@ on:
push:
tags:
- '*'
workflow_dispatch:
inputs:
tag:
description: 'Tag name to build (e.g., v0.10.8-alpha.3)'
required: true
type: string
jobs:
build_single_arch:
@@ -25,15 +31,24 @@ jobs:
contents: read
steps:
- name: Check out (shallow)
- name: Check out
uses: actions/checkout@v4
with:
fetch-depth: 1
fetch-depth: ${{ github.event_name == 'workflow_dispatch' && 0 || 1 }}
ref: ${{ github.event.inputs.tag || github.ref }}
- name: Resolve tag & write VERSION
run: |
git fetch --tags --force --depth=1
TAG=${GITHUB_REF#refs/tags/}
if [ -n "${{ github.event.inputs.tag }}" ]; then
TAG="${{ github.event.inputs.tag }}"
# Verify tag exists
if ! git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then
echo "Error: Tag '$TAG' does not exist in the repository"
exit 1
fi
else
TAG=${GITHUB_REF#refs/tags/}
fi
echo "TAG=$TAG" >> $GITHUB_ENV
echo "$TAG" > VERSION
echo "Building tag: $TAG for ${{ matrix.arch }}"
@@ -87,10 +102,15 @@ jobs:
name: Create multi-arch manifests (Docker Hub)
needs: [build_single_arch]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
if: startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch'
steps:
- name: Extract tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
run: |
if [ -n "${{ github.event.inputs.tag }}" ]; then
echo "TAG=${{ github.event.inputs.tag }}" >> $GITHUB_ENV
else
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
fi
#
# - name: Normalize GHCR repository
# run: echo "GHCR_REPOSITORY=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV

View File

@@ -445,6 +445,14 @@ Bienvenue à toutes les formes de contribution!
---
## 📜 Licence
Ce projet est sous licence [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
Si les politiques de votre organisation ne permettent pas l'utilisation de logiciels sous licence AGPLv3, ou si vous souhaitez éviter les obligations open-source de l'AGPLv3, veuillez nous contacter à : [support@quantumnous.com](mailto:support@quantumnous.com)
---
## 🌟 Historique des étoiles
<div align="center">

View File

@@ -445,6 +445,14 @@ docker run --name new-api -d --restart always \
---
## 📜 ライセンス
このプロジェクトは [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE) の下でライセンスされています。
お客様の組織のポリシーがAGPLv3ライセンスのソフトウェアの使用を許可していない場合、またはAGPLv3のオープンソース義務を回避したい場合は、こちらまでお問い合わせください[support@quantumnous.com](mailto:support@quantumnous.com)
---
## 🌟 スター履歴
<div align="center">

View File

@@ -445,6 +445,14 @@ Welcome all forms of contribution!
---
## 📜 License
This project is licensed under the [GNU Affero General Public License v3.0 (AGPLv3)](./LICENSE).
If your organization's policies do not permit the use of AGPLv3-licensed software, or if you wish to avoid the open-source obligations of AGPLv3, please contact us at: [support@quantumnous.com](mailto:support@quantumnous.com)
---
## 🌟 Star History
<div align="center">

View File

@@ -445,6 +445,14 @@ docker run --name new-api -d --restart always \
---
## 📜 许可证
本项目采用 [GNU Affero 通用公共许可证 v3.0 (AGPLv3)](./LICENSE) 授权。
如果您所在的组织政策不允许使用 AGPLv3 许可的软件,或您希望规避 AGPLv3 的开源义务,请发送邮件至:[support@quantumnous.com](mailto:support@quantumnous.com)
---
## 🌟 Star History
<div align="center">

View File

@@ -5,12 +5,9 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/google/uuid"
)
// BodyStorage 请求体存储接口
@@ -101,25 +98,10 @@ type diskStorage struct {
}
func newDiskStorage(data []byte, cachePath string) (*diskStorage, error) {
// 确定缓存目录
dir := cachePath
if dir == "" {
dir = os.TempDir()
}
dir = filepath.Join(dir, "new-api-body-cache")
// 确保目录存在
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
// 创建临时文件
filename := fmt.Sprintf("body-%s-%d.tmp", uuid.New().String()[:8], time.Now().UnixNano())
filePath := filepath.Join(dir, filename)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
// 使用统一的缓存目录管理
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
return nil, err
}
// 写入数据
@@ -148,25 +130,10 @@ func newDiskStorage(data []byte, cachePath string) (*diskStorage, error) {
}
func newDiskStorageFromReader(reader io.Reader, maxBytes int64, cachePath string) (*diskStorage, error) {
// 确定缓存目录
dir := cachePath
if dir == "" {
dir = os.TempDir()
}
dir = filepath.Join(dir, "new-api-body-cache")
// 确保目录存在
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create cache directory: %w", err)
}
// 创建临时文件
filename := fmt.Sprintf("body-%s-%d.tmp", uuid.New().String()[:8], time.Now().UnixNano())
filePath := filepath.Join(dir, filename)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
// 使用统一的缓存目录管理
filePath, file, err := CreateDiskCacheFile(DiskCacheTypeBody)
if err != nil {
return nil, fmt.Errorf("failed to create temp file: %w", err)
return nil, err
}
// 从 reader 读取并写入文件
@@ -337,29 +304,6 @@ func CreateBodyStorageFromReader(reader io.Reader, contentLength int64, maxBytes
// CleanupOldCacheFiles 清理旧的缓存文件(用于启动时清理残留)
func CleanupOldCacheFiles() {
cachePath := GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}
dir := filepath.Join(cachePath, "new-api-body-cache")
entries, err := os.ReadDir(dir)
if err != nil {
return // 目录不存在或无法读取
}
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
// 删除超过 5 分钟的旧文件
if now.Sub(info.ModTime()) > 5*time.Minute {
os.Remove(filepath.Join(dir, entry.Name()))
}
}
// 使用统一的缓存管理
CleanupOldDiskCacheFiles(5 * time.Minute)
}

176
common/disk_cache.go Normal file
View File

@@ -0,0 +1,176 @@
package common
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
)
// DiskCacheType 磁盘缓存类型
type DiskCacheType string
const (
DiskCacheTypeBody DiskCacheType = "body" // 请求体缓存
DiskCacheTypeFile DiskCacheType = "file" // 文件数据缓存
)
// 统一的缓存目录名
const diskCacheDir = "new-api-body-cache"
// GetDiskCacheDir 获取统一的磁盘缓存目录
// 注意:每次调用都会重新计算,以响应配置变化
func GetDiskCacheDir() string {
cachePath := GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}
return filepath.Join(cachePath, diskCacheDir)
}
// EnsureDiskCacheDir 确保缓存目录存在
func EnsureDiskCacheDir() error {
dir := GetDiskCacheDir()
return os.MkdirAll(dir, 0755)
}
// CreateDiskCacheFile 创建磁盘缓存文件
// cacheType: 缓存类型body/file
// 返回文件路径和文件句柄
func CreateDiskCacheFile(cacheType DiskCacheType) (string, *os.File, error) {
if err := EnsureDiskCacheDir(); err != nil {
return "", nil, fmt.Errorf("failed to create cache directory: %w", err)
}
dir := GetDiskCacheDir()
filename := fmt.Sprintf("%s-%s-%d.tmp", cacheType, uuid.New().String()[:8], time.Now().UnixNano())
filePath := filepath.Join(dir, filename)
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_RDWR|os.O_EXCL, 0600)
if err != nil {
return "", nil, fmt.Errorf("failed to create cache file: %w", err)
}
return filePath, file, nil
}
// WriteDiskCacheFile 写入数据到磁盘缓存文件
// 返回文件路径
func WriteDiskCacheFile(cacheType DiskCacheType, data []byte) (string, error) {
filePath, file, err := CreateDiskCacheFile(cacheType)
if err != nil {
return "", err
}
_, err = file.Write(data)
if err != nil {
file.Close()
os.Remove(filePath)
return "", fmt.Errorf("failed to write cache file: %w", err)
}
if err := file.Close(); err != nil {
os.Remove(filePath)
return "", fmt.Errorf("failed to close cache file: %w", err)
}
return filePath, nil
}
// WriteDiskCacheFileString 写入字符串到磁盘缓存文件
func WriteDiskCacheFileString(cacheType DiskCacheType, data string) (string, error) {
return WriteDiskCacheFile(cacheType, []byte(data))
}
// ReadDiskCacheFile 读取磁盘缓存文件
func ReadDiskCacheFile(filePath string) ([]byte, error) {
return os.ReadFile(filePath)
}
// ReadDiskCacheFileString 读取磁盘缓存文件为字符串
func ReadDiskCacheFileString(filePath string) (string, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return "", err
}
return string(data), nil
}
// RemoveDiskCacheFile 删除磁盘缓存文件
func RemoveDiskCacheFile(filePath string) error {
return os.Remove(filePath)
}
// CleanupOldDiskCacheFiles 清理旧的缓存文件
// maxAge: 文件最大存活时间
// 注意:此函数只删除文件,不更新统计(因为无法知道每个文件的原始大小)
func CleanupOldDiskCacheFiles(maxAge time.Duration) error {
dir := GetDiskCacheDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil // 目录不存在,无需清理
}
return err
}
now := time.Now()
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) > maxAge {
// 注意:后台清理任务删除文件时,由于无法得知原始 base64Size
// 只能按磁盘文件大小扣减。这在目前 base64 存储模式下是准确的。
if err := os.Remove(filepath.Join(dir, entry.Name())); err == nil {
DecrementDiskFiles(info.Size())
}
}
}
return nil
}
// GetDiskCacheInfo 获取磁盘缓存目录信息
func GetDiskCacheInfo() (fileCount int, totalSize int64, err error) {
dir := GetDiskCacheDir()
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return 0, 0, nil
}
return 0, 0, err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
fileCount++
totalSize += info.Size()
}
return fileCount, totalSize, nil
}
// ShouldUseDiskCache 判断是否应该使用磁盘缓存
func ShouldUseDiskCache(dataSize int64) bool {
if !IsDiskCacheEnabled() {
return false
}
threshold := GetDiskCacheThresholdBytes()
if dataSize < threshold {
return false
}
return IsDiskCacheAvailable(dataSize)
}

View File

@@ -113,8 +113,12 @@ func IncrementDiskFiles(size int64) {
// DecrementDiskFiles 减少磁盘文件计数
func DecrementDiskFiles(size int64) {
atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, -1)
atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, -size)
if atomic.AddInt64(&diskCacheStats.ActiveDiskFiles, -1) < 0 {
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
}
if atomic.AddInt64(&diskCacheStats.CurrentDiskUsageBytes, -size) < 0 {
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
}
}
// IncrementMemoryBuffers 增加内存缓存计数
@@ -139,12 +143,29 @@ func IncrementMemoryCacheHits() {
atomic.AddInt64(&diskCacheStats.MemoryCacheHits, 1)
}
// ResetDiskCacheStats 重置统计信息(不重置当前使用量)
// ResetDiskCacheStats 重置命中统计信息(不重置当前使用量)
func ResetDiskCacheStats() {
atomic.StoreInt64(&diskCacheStats.DiskCacheHits, 0)
atomic.StoreInt64(&diskCacheStats.MemoryCacheHits, 0)
}
// ResetDiskCacheUsage 重置磁盘缓存使用量统计(用于清理缓存后)
func ResetDiskCacheUsage() {
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, 0)
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, 0)
}
// SyncDiskCacheStats 从实际磁盘状态同步统计信息
// 用于修正统计与实际不符的情况
func SyncDiskCacheStats() {
fileCount, totalSize, err := GetDiskCacheInfo()
if err != nil {
return
}
atomic.StoreInt64(&diskCacheStats.ActiveDiskFiles, int64(fileCount))
atomic.StoreInt64(&diskCacheStats.CurrentDiskUsageBytes, totalSize)
}
// IsDiskCacheAvailable 检查是否可以创建新的磁盘缓存
func IsDiskCacheAvailable(requestSize int64) bool {
if !IsDiskCacheEnabled() {

View File

@@ -137,7 +137,6 @@ func initConstantEnv() {
constant.GetMediaTokenNotStream = GetEnvOrDefaultBool("GET_MEDIA_TOKEN_NOT_STREAM", false)
constant.UpdateTask = GetEnvOrDefaultBool("UPDATE_TASK", true)
constant.AzureDefaultAPIVersion = GetEnvOrDefaultString("AZURE_DEFAULT_API_VERSION", "2025-04-01-preview")
constant.GeminiVisionMaxImageNum = GetEnvOrDefault("GEMINI_VISION_MAX_IMAGE_NUM", 16)
constant.NotifyLimitCount = GetEnvOrDefault("NOTIFY_LIMIT_COUNT", 2)
constant.NotificationLimitDurationMinute = GetEnvOrDefault("NOTIFICATION_LIMIT_DURATION_MINUTE", 10)
// GenerateDefaultToken 是否生成初始令牌,默认关闭。

View File

@@ -0,0 +1,33 @@
package common
import "sync/atomic"
// PerformanceMonitorConfig 性能监控配置
type PerformanceMonitorConfig struct {
Enabled bool
CPUThreshold int
MemoryThreshold int
DiskThreshold int
}
var performanceMonitorConfig atomic.Value
func init() {
// 初始化默认配置
performanceMonitorConfig.Store(PerformanceMonitorConfig{
Enabled: true,
CPUThreshold: 90,
MemoryThreshold: 90,
DiskThreshold: 90,
})
}
// GetPerformanceMonitorConfig 获取性能监控配置
func GetPerformanceMonitorConfig() PerformanceMonitorConfig {
return performanceMonitorConfig.Load().(PerformanceMonitorConfig)
}
// SetPerformanceMonitorConfig 设置性能监控配置
func SetPerformanceMonitorConfig(config PerformanceMonitorConfig) {
performanceMonitorConfig.Store(config)
}

81
common/system_monitor.go Normal file
View File

@@ -0,0 +1,81 @@
package common
import (
"sync/atomic"
"time"
"github.com/shirou/gopsutil/cpu"
"github.com/shirou/gopsutil/mem"
)
// DiskSpaceInfo 磁盘空间信息
type DiskSpaceInfo struct {
// 总空间(字节)
Total uint64 `json:"total"`
// 可用空间(字节)
Free uint64 `json:"free"`
// 已用空间(字节)
Used uint64 `json:"used"`
// 使用百分比
UsedPercent float64 `json:"used_percent"`
}
// SystemStatus 系统状态信息
type SystemStatus struct {
CPUUsage float64
MemoryUsage float64
DiskUsage float64
}
var latestSystemStatus atomic.Value
func init() {
latestSystemStatus.Store(SystemStatus{})
}
// StartSystemMonitor 启动系统监控
func StartSystemMonitor() {
go func() {
for {
config := GetPerformanceMonitorConfig()
if !config.Enabled {
time.Sleep(30 * time.Second)
continue
}
updateSystemStatus()
time.Sleep(5 * time.Second)
}
}()
}
func updateSystemStatus() {
var status SystemStatus
// CPU
// 注意cpu.Percent(0, false) 返回自上次调用以来的 CPU 使用率
// 如果是第一次调用,可能会返回错误或不准确的值,但在循环中会逐渐正常
percents, err := cpu.Percent(0, false)
if err == nil && len(percents) > 0 {
status.CPUUsage = percents[0]
}
// Memory
memInfo, err := mem.VirtualMemory()
if err == nil {
status.MemoryUsage = memInfo.UsedPercent
}
// Disk
diskInfo := GetDiskSpaceInfo()
if diskInfo.Total > 0 {
status.DiskUsage = diskInfo.UsedPercent
}
latestSystemStatus.Store(status)
}
// GetSystemStatus 获取当前系统状态
func GetSystemStatus() SystemStatus {
return latestSystemStatus.Load().(SystemStatus)
}

View File

@@ -1,17 +1,16 @@
//go:build !windows
package controller
package common
import (
"os"
"github.com/QuantumNous/new-api/common"
"golang.org/x/sys/unix"
)
// getDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Unix/Linux/macOS)
func getDiskSpaceInfo() DiskSpaceInfo {
cachePath := common.GetDiskCachePath()
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Unix/Linux/macOS)
func GetDiskSpaceInfo() DiskSpaceInfo {
cachePath := GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}

View File

@@ -1,18 +1,16 @@
//go:build windows
package controller
package common
import (
"os"
"syscall"
"unsafe"
"github.com/QuantumNous/new-api/common"
)
// getDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Windows)
func getDiskSpaceInfo() DiskSpaceInfo {
cachePath := common.GetDiskCachePath()
// GetDiskSpaceInfo 获取缓存目录所在磁盘的空间信息 (Windows)
func GetDiskSpaceInfo() DiskSpaceInfo {
cachePath := GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}

View File

@@ -56,6 +56,9 @@ const (
ContextKeySystemPromptOverride ContextKey = "system_prompt_override"
// ContextKeyFileSourcesToCleanup stores file sources that need cleanup when request ends
ContextKeyFileSourcesToCleanup ContextKey = "file_sources_to_cleanup"
// ContextKeyAdminRejectReason stores an admin-only reject/block reason extracted from upstream responses.
// It is not returned to end users, but can be persisted into consume/error logs for debugging.
ContextKeyAdminRejectReason ContextKey = "admin_reject_reason"

View File

@@ -11,7 +11,6 @@ var GetMediaTokenNotStream bool
var UpdateTask bool
var MaxRequestBodyMB int
var AzureDefaultAPIVersion string
var GeminiVisionMaxImageNum int
var NotifyLimitCount int
var NotificationLimitDurationMinute int
var GenerateDefaultToken bool

View File

@@ -89,7 +89,8 @@ func GetAllChannels(c *gin.Context) {
if enableTagMode {
tags, err := model.GetPaginatedTags(pageInfo.GetStartIdx(), pageInfo.GetPageSize())
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to get paginated tags: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取标签失败,请稍后重试"})
return
}
for _, tag := range tags {
@@ -136,7 +137,8 @@ func GetAllChannels(c *gin.Context) {
err := baseQuery.Order(order).Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Omit("key").Find(&channelData).Error
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to get channels: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道列表失败,请稍后重试"})
return
}
}
@@ -641,7 +643,8 @@ func RefreshCodexChannelCredential(c *gin.Context) {
oauthKey, ch, err := service.RefreshCodexChannelCredential(ctx, channelId, service.CodexCredentialRefreshOptions{ResetCaches: true})
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to refresh codex channel credential: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "刷新凭证失败,请稍后重试"})
return
}
@@ -1315,7 +1318,8 @@ func CopyChannel(c *gin.Context) {
// fetch original channel with key
origin, err := model.GetChannelById(id, true)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to get channel by id: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取渠道信息失败,请稍后重试"})
return
}
@@ -1333,7 +1337,8 @@ func CopyChannel(c *gin.Context) {
// insert
if err := model.BatchInsertChannels([]model.Channel{clone}); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to clone channel: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "复制渠道失败,请稍后重试"})
return
}
model.InitChannelCache()

View File

@@ -132,7 +132,8 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
code, state, err := parseCodexAuthorizationInput(req.Input)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to parse codex authorization input: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析授权信息失败,请检查输入格式"})
return
}
if strings.TrimSpace(code) == "" {
@@ -177,7 +178,8 @@ func completeCodexOAuthWithChannelID(c *gin.Context, channelID int) {
tokenRes, err := service.ExchangeCodexAuthorizationCode(ctx, code, verifier)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to exchange codex authorization code: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "授权码交换失败,请重试"})
return
}

View File

@@ -45,7 +45,8 @@ func GetCodexChannelUsage(c *gin.Context) {
oauthKey, err := codex.ParseOAuthKey(strings.TrimSpace(ch.Key))
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to parse oauth key: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "解析凭证失败,请检查渠道配置"})
return
}
accessToken := strings.TrimSpace(oauthKey.AccessToken)
@@ -70,7 +71,8 @@ func GetCodexChannelUsage(c *gin.Context) {
statusCode, body, err := service.FetchCodexWhamUsage(ctx, client, ch.GetBaseURL(), accessToken, accountID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to fetch codex usage: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"})
return
}
@@ -99,7 +101,8 @@ func GetCodexChannelUsage(c *gin.Context) {
defer cancel2()
statusCode, body, err = service.FetchCodexWhamUsage(ctx2, client, ch.GetBaseURL(), oauthKey.AccessToken, accountID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to fetch codex usage after refresh: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取用量信息失败,请稍后重试"})
return
}
}

View File

@@ -17,7 +17,8 @@ func MigrateConsoleSetting(c *gin.Context) {
// 读取全部 option
opts, err := model.AllOption()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to get all options: " + err.Error())
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "获取配置失败,请稍后重试"})
return
}
// 建立 map

View File

@@ -20,7 +20,8 @@ func GetAllLogs(c *gin.Context) {
modelName := c.Query("model_name")
channel, _ := strconv.Atoi(c.Query("channel"))
group := c.Query("group")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group)
requestId := c.Query("request_id")
logs, total, err := model.GetAllLogs(logType, startTimestamp, endTimestamp, modelName, username, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), channel, group, requestId)
if err != nil {
common.ApiError(c, err)
return
@@ -40,7 +41,8 @@ func GetUserLogs(c *gin.Context) {
tokenName := c.Query("token_name")
modelName := c.Query("model_name")
group := c.Query("group")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group)
requestId := c.Query("request_id")
logs, total, err := model.GetUserLogs(userId, logType, startTimestamp, endTimestamp, modelName, tokenName, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), group, requestId)
if err != nil {
common.ApiError(c, err)
return

View File

@@ -272,7 +272,8 @@ func SyncUpstreamModels(c *gin.Context) {
// 1) 获取未配置模型列表
missing, err := model.GetMissingModels()
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to get missing models: " + err.Error())
c.JSON(http.StatusOK, gin.H{"success": false, "message": "获取模型列表失败,请稍后重试"})
return
}

View File

@@ -3,8 +3,8 @@ package controller
import (
"net/http"
"os"
"path/filepath"
"runtime"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/gin-gonic/gin"
@@ -19,7 +19,7 @@ type PerformanceStats struct {
// 磁盘缓存目录信息
DiskCacheInfo DiskCacheInfo `json:"disk_cache_info"`
// 磁盘空间信息
DiskSpaceInfo DiskSpaceInfo `json:"disk_space_info"`
DiskSpaceInfo common.DiskSpaceInfo `json:"disk_space_info"`
// 配置信息
Config PerformanceConfig `json:"config"`
}
@@ -50,18 +50,6 @@ type DiskCacheInfo struct {
TotalSize int64 `json:"total_size"`
}
// DiskSpaceInfo 磁盘空间信息
type DiskSpaceInfo struct {
// 总空间(字节)
Total uint64 `json:"total"`
// 可用空间(字节)
Free uint64 `json:"free"`
// 已用空间(字节)
Used uint64 `json:"used"`
// 使用百分比
UsedPercent float64 `json:"used_percent"`
}
// PerformanceConfig 性能配置
type PerformanceConfig struct {
// 是否启用磁盘缓存
@@ -74,11 +62,21 @@ type PerformanceConfig struct {
DiskCachePath string `json:"disk_cache_path"`
// 是否在容器中运行
IsRunningInContainer bool `json:"is_running_in_container"`
// MonitorEnabled 是否启用性能监控
MonitorEnabled bool `json:"monitor_enabled"`
// MonitorCPUThreshold CPU 使用率阈值(%
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
// MonitorMemoryThreshold 内存使用率阈值(%
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
// MonitorDiskThreshold 磁盘使用率阈值(%
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
}
// GetPerformanceStats 获取性能统计信息
func GetPerformanceStats(c *gin.Context) {
// 获取缓存统计
// 不再每次获取统计都全量扫描磁盘,依赖原子计数器保证性能
// 仅在系统启动或显式清理时同步
cacheStats := common.GetDiskCacheStats()
// 获取内存统计
@@ -90,16 +88,30 @@ func GetPerformanceStats(c *gin.Context) {
// 获取配置信息
diskConfig := common.GetDiskCacheConfig()
monitorConfig := common.GetPerformanceMonitorConfig()
config := PerformanceConfig{
DiskCacheEnabled: diskConfig.Enabled,
DiskCacheThresholdMB: diskConfig.ThresholdMB,
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
DiskCachePath: diskConfig.Path,
IsRunningInContainer: common.IsRunningInContainer(),
DiskCacheEnabled: diskConfig.Enabled,
DiskCacheThresholdMB: diskConfig.ThresholdMB,
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
DiskCachePath: diskConfig.Path,
IsRunningInContainer: common.IsRunningInContainer(),
MonitorEnabled: monitorConfig.Enabled,
MonitorCPUThreshold: monitorConfig.CPUThreshold,
MonitorMemoryThreshold: monitorConfig.MemoryThreshold,
MonitorDiskThreshold: monitorConfig.DiskThreshold,
}
// 获取磁盘空间信息
diskSpaceInfo := getDiskSpaceInfo()
// 使用缓存的系统状态,避免频繁调用系统 API
systemStatus := common.GetSystemStatus()
diskSpaceInfo := common.DiskSpaceInfo{
UsedPercent: systemStatus.DiskUsage,
}
// 如果需要详细信息,可以按需获取,或者扩展 SystemStatus
// 这里为了保持接口兼容性,我们仍然调用 GetDiskSpaceInfo但注意这可能会有性能开销
// 考虑到 GetPerformanceStats 是管理接口,频率较低,直接调用是可以接受的
// 但为了一致性,我们也可以考虑从 SystemStatus 中获取部分信息
diskSpaceInfo = common.GetDiskSpaceInfo()
stats := PerformanceStats{
CacheStats: cacheStats,
@@ -121,27 +133,19 @@ func GetPerformanceStats(c *gin.Context) {
})
}
// ClearDiskCache 清理磁盘缓存
// ClearDiskCache 清理不活跃的磁盘缓存
func ClearDiskCache(c *gin.Context) {
cachePath := common.GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}
dir := filepath.Join(cachePath, "new-api-body-cache")
// 删除缓存目录
err := os.RemoveAll(dir)
if err != nil && !os.IsNotExist(err) {
// 清理超过 10 分钟未使用的缓存文件
// 10 分钟是一个安全的阈值,确保正在进行的请求不会被误删
err := common.CleanupOldDiskCacheFiles(10 * time.Minute)
if err != nil {
common.ApiError(c, err)
return
}
// 重置统计
common.ResetDiskCacheStats()
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "磁盘缓存已清理",
"message": "不活跃的磁盘缓存已清理",
})
}
@@ -167,11 +171,8 @@ func ForceGC(c *gin.Context) {
// getDiskCacheInfo 获取磁盘缓存目录信息
func getDiskCacheInfo() DiskCacheInfo {
cachePath := common.GetDiskCachePath()
if cachePath == "" {
cachePath = os.TempDir()
}
dir := filepath.Join(cachePath, "new-api-body-cache")
// 使用统一的缓存目录
dir := common.GetDiskCacheDir()
info := DiskCacheInfo{
Path: dir,

View File

@@ -56,7 +56,8 @@ type upstreamResult struct {
func FetchUpstreamRatios(c *gin.Context) {
var req dto.UpstreamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
common.SysError("failed to bind upstream request: " + err.Error())
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求参数格式错误"})
return
}

View File

@@ -103,9 +103,10 @@ func AddRedemption(c *gin.Context) {
}
err = cleanRedemption.Insert()
if err != nil {
common.SysError("failed to insert redemption: " + err.Error())
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": "创建兑换码失败,请稍后重试",
"data": keys,
})
return

View File

@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"strings"
"time"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
@@ -373,7 +374,12 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
}
service.AppendChannelAffinityAdminInfo(c, adminInfo)
other["admin_info"] = adminInfo
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, 0, false, userGroup, other)
startTime := common.GetContextKeyTime(c, constant.ContextKeyRequestStartTime)
if startTime.IsZero() {
startTime = time.Now()
}
useTimeSeconds := int(time.Since(startTime).Seconds())
model.RecordErrorLog(c, userId, channelId, modelName, tokenName, err.MaskSensitiveErrorWithStatusCode(), tokenId, useTimeSeconds, false, userGroup, other)
}
}

View File

@@ -118,6 +118,14 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
common.ApiErrorMsg(c, "套餐标题不能为空")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
return
}
if req.Plan.Currency == "" {
req.Plan.Currency = "USD"
}
@@ -172,6 +180,14 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
common.ApiErrorMsg(c, "套餐标题不能为空")
return
}
if req.Plan.PriceAmount < 0 {
common.ApiErrorMsg(c, "价格不能为负数")
return
}
if req.Plan.PriceAmount > 9999 {
common.ApiErrorMsg(c, "价格不能超过9999")
return
}
req.Plan.Id = id
if req.Plan.Currency == "" {
req.Plan.Currency = "USD"

View File

@@ -108,25 +108,35 @@ func SubscriptionRequestEpay(c *gin.Context) {
common.ApiErrorMsg(c, "拉起支付失败")
return
}
common.ApiSuccess(c, gin.H{"data": params, "url": uri})
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
}
func SubscriptionEpayNotify(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
if len(params) == 0 {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
_, _ = c.Writer.Write([]byte("fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
_, _ = c.Writer.Write([]byte("fail"))
@@ -157,21 +167,31 @@ func SubscriptionEpayNotify(c *gin.Context) {
// SubscriptionEpayReturn handles browser return after payment.
// It verifies the payload and completes the order, then redirects to console.
func SubscriptionEpayReturn(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
return
}
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
if len(params) == 0 {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
return
}
client := GetEpayClient()
if client == nil {
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")

View File

@@ -107,9 +107,10 @@ func GetTokenUsage(c *gin.Context) {
token, err := model.GetTokenByKey(strings.TrimPrefix(tokenKey, "sk-"), false)
if err != nil {
common.SysError("failed to get token by key: " + err.Error())
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
"message": "获取令牌信息失败,请稍后重试",
})
return
}

View File

@@ -228,21 +228,32 @@ func UnlockOrder(tradeNo string) {
}
func EpayNotify(c *gin.Context) {
if err := c.Request.ParseForm(); err != nil {
log.Println("易支付回调解析失败:", err)
_, _ = c.Writer.Write([]byte("fail"))
return
}
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
if len(params) == 0 {
var params map[string]string
if c.Request.Method == "POST" {
// POST 请求:从 POST body 解析参数
if err := c.Request.ParseForm(); err != nil {
log.Println("易支付回调POST解析失败:", err)
_, _ = c.Writer.Write([]byte("fail"))
return
}
params = lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.PostForm.Get(t)
return r
}, map[string]string{})
} else {
// GET 请求:从 URL Query 解析参数
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
r[t] = c.Request.URL.Query().Get(t)
return r
}, map[string]string{})
}
if len(params) == 0 {
log.Println("易支付回调参数为空")
_, _ = c.Writer.Write([]byte("fail"))
return
}
client := GetEpayClient()
if client == nil {
log.Println("易支付回调失败 未找到配置信息")

View File

@@ -214,6 +214,14 @@ type ClaudeRequest struct {
ServiceTier string `json:"service_tier,omitempty"`
}
// createClaudeFileSource 根据数据内容创建正确类型的 FileSource
func createClaudeFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta = types.TokenCountMeta{
TokenType: types.TokenTypeTokenizer,
@@ -243,7 +251,10 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
}
}
@@ -275,7 +286,10 @@ func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta {
data = common.Interface2String(media.Source.Data)
}
if data != "" {
fileMeta = append(fileMeta, &types.FileMeta{FileType: types.FileTypeImage, OriginData: data})
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: createClaudeFileSource(data),
})
}
}
case "tool_use":

View File

@@ -64,6 +64,14 @@ type LatLng struct {
Longitude *float64 `json:"longitude,omitempty"`
}
// createGeminiFileSource 根据数据内容创建正确类型的 FileSource
func createGeminiFileSource(data string, mimeType string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, mimeType)
}
func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
var files []*types.FileMeta = make([]*types.FileMeta, 0)
@@ -80,27 +88,23 @@ func (r *GeminiChatRequest) GetTokenCountMeta() *types.TokenCountMeta {
inputTexts = append(inputTexts, part.Text)
}
if part.InlineData != nil && part.InlineData.Data != "" {
if strings.HasPrefix(part.InlineData.MimeType, "image/") {
files = append(files, &types.FileMeta{
FileType: types.FileTypeImage,
OriginData: part.InlineData.Data,
})
} else if strings.HasPrefix(part.InlineData.MimeType, "audio/") {
files = append(files, &types.FileMeta{
FileType: types.FileTypeAudio,
OriginData: part.InlineData.Data,
})
} else if strings.HasPrefix(part.InlineData.MimeType, "video/") {
files = append(files, &types.FileMeta{
FileType: types.FileTypeVideo,
OriginData: part.InlineData.Data,
})
mimeType := part.InlineData.MimeType
source := createGeminiFileSource(part.InlineData.Data, mimeType)
var fileType types.FileType
if strings.HasPrefix(mimeType, "image/") {
fileType = types.FileTypeImage
} else if strings.HasPrefix(mimeType, "audio/") {
fileType = types.FileTypeAudio
} else if strings.HasPrefix(mimeType, "video/") {
fileType = types.FileTypeVideo
} else {
files = append(files, &types.FileMeta{
FileType: types.FileTypeFile,
OriginData: part.InlineData.Data,
})
fileType = types.FileTypeFile
}
files = append(files, &types.FileMeta{
FileType: fileType,
Source: source,
MimeType: mimeType,
})
}
}
}

View File

@@ -101,6 +101,14 @@ type GeneralOpenAIRequest struct {
SearchMode string `json:"search_mode,omitempty"`
}
// createFileSource 根据数据内容创建正确类型的 FileSource
func createFileSource(data string) *types.FileSource {
if strings.HasPrefix(data, "http://") || strings.HasPrefix(data, "https://") {
return types.NewURLFileSource(data)
}
return types.NewBase64FileSource(data, "")
}
func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
var tokenCountMeta types.TokenCountMeta
var texts = make([]string, 0)
@@ -144,42 +152,40 @@ func (r *GeneralOpenAIRequest) GetTokenCountMeta() *types.TokenCountMeta {
for _, m := range arrayContent {
if m.Type == ContentTypeImageURL {
imageUrl := m.GetImageMedia()
if imageUrl != nil {
if imageUrl.Url != "" {
meta := &types.FileMeta{
FileType: types.FileTypeImage,
}
meta.OriginData = imageUrl.Url
meta.Detail = imageUrl.Detail
fileMeta = append(fileMeta, meta)
}
if imageUrl != nil && imageUrl.Url != "" {
source := createFileSource(imageUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
Source: source,
Detail: imageUrl.Detail,
})
}
} else if m.Type == ContentTypeInputAudio {
inputAudio := m.GetInputAudio()
if inputAudio != nil {
meta := &types.FileMeta{
if inputAudio != nil && inputAudio.Data != "" {
source := createFileSource(inputAudio.Data)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeAudio,
}
meta.OriginData = inputAudio.Data
fileMeta = append(fileMeta, meta)
Source: source,
})
}
} else if m.Type == ContentTypeFile {
file := m.GetFile()
if file != nil {
meta := &types.FileMeta{
if file != nil && file.FileData != "" {
source := createFileSource(file.FileData)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
}
meta.OriginData = file.FileData
fileMeta = append(fileMeta, meta)
Source: source,
})
}
} else if m.Type == ContentTypeVideoUrl {
videoUrl := m.GetVideoUrl()
if videoUrl != nil && videoUrl.Url != "" {
meta := &types.FileMeta{
source := createFileSource(videoUrl.Url)
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeVideo,
}
meta.OriginData = videoUrl.Url
fileMeta = append(fileMeta, meta)
Source: source,
})
}
} else {
texts = append(texts, m.Text)
@@ -833,16 +839,16 @@ func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
if input.Type == "input_image" {
if input.ImageUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeImage,
OriginData: input.ImageUrl,
Detail: input.Detail,
FileType: types.FileTypeImage,
Source: createFileSource(input.ImageUrl),
Detail: input.Detail,
})
}
} else if input.Type == "input_file" {
if input.FileUrl != "" {
fileMeta = append(fileMeta, &types.FileMeta{
FileType: types.FileTypeFile,
OriginData: input.FileUrl,
FileType: types.FileTypeFile,
Source: createFileSource(input.FileUrl),
})
}
} else {

View File

@@ -274,5 +274,9 @@ func InitResources() error {
if err != nil {
return err
}
// 启动系统监控
common.StartSystemMonitor()
return nil
}

View File

@@ -2,6 +2,7 @@ package middleware
import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
@@ -14,5 +15,8 @@ func BodyStorageCleanup() gin.HandlerFunc {
// 请求结束后清理存储
common.CleanupBodyStorage(c)
// 清理文件缓存URL 下载的文件等)
service.CleanupFileSources(c)
}
}

65
middleware/performance.go Normal file
View File

@@ -0,0 +1,65 @@
package middleware
import (
"errors"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
)
// SystemPerformanceCheck 检查系统性能中间件
func SystemPerformanceCheck() gin.HandlerFunc {
return func(c *gin.Context) {
// 仅检查 Relay 接口 (/v1, /v1beta 等)
// 这里简单判断路径前缀,可以根据实际路由调整
path := c.Request.URL.Path
if strings.HasPrefix(path, "/v1/messages") {
if err := checkSystemPerformance(); err != nil {
c.JSON(err.StatusCode, gin.H{
"error": err.ToClaudeError(),
})
c.Abort()
return
}
} else {
if err := checkSystemPerformance(); err != nil {
c.JSON(err.StatusCode, gin.H{
"error": err.ToOpenAIError(),
})
c.Abort()
return
}
}
c.Next()
}
}
// checkSystemPerformance 检查系统性能是否超过阈值
func checkSystemPerformance() *types.NewAPIError {
config := common.GetPerformanceMonitorConfig()
if !config.Enabled {
return nil
}
status := common.GetSystemStatus()
// 检查 CPU
if config.CPUThreshold > 0 && int(status.CPUUsage) > config.CPUThreshold {
return types.NewErrorWithStatusCode(errors.New("system cpu overloaded"), "system_cpu_overloaded", http.StatusServiceUnavailable)
}
// 检查内存
if config.MemoryThreshold > 0 && int(status.MemoryUsage) > config.MemoryThreshold {
return types.NewErrorWithStatusCode(errors.New("system memory overloaded"), "system_memory_overloaded", http.StatusServiceUnavailable)
}
// 检查磁盘
if config.DiskThreshold > 0 && int(status.DiskUsage) > config.DiskThreshold {
return types.NewErrorWithStatusCode(errors.New("system disk overloaded"), "system_disk_overloaded", http.StatusServiceUnavailable)
}
return nil
}

View File

@@ -36,6 +36,7 @@ type Log struct {
TokenId int `json:"token_id" gorm:"default:0;index"`
Group string `json:"group" gorm:"index"`
Ip string `json:"ip" gorm:"index;default:''"`
RequestId string `json:"request_id,omitempty" gorm:"type:varchar(64);index:idx_logs_request_id;default:''"`
Other string `json:"other"`
}
@@ -58,7 +59,6 @@ func formatUserLogs(logs []*Log) {
if otherMap != nil {
// Remove admin-only debug fields.
delete(otherMap, "admin_info")
delete(otherMap, "request_conversion")
delete(otherMap, "reject_reason")
}
logs[i].Other = common.MapToJsonStr(otherMap)
@@ -102,6 +102,7 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
isStream bool, group string, other map[string]interface{}) {
logger.LogInfo(c, fmt.Sprintf("record error log: userId=%d, channelId=%d, modelName=%s, tokenName=%s, content=%s", userId, channelId, modelName, tokenName, content))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
otherStr := common.MapToJsonStr(other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -132,7 +133,8 @@ func RecordErrorLog(c *gin.Context, userId int, channelId int, modelName string,
}
return ""
}(),
Other: otherStr,
RequestId: requestId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -161,6 +163,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
logger.LogInfo(c, fmt.Sprintf("record consume log: userId=%d, params=%s", userId, common.GetJsonString(params)))
username := c.GetString("username")
requestId := c.GetString(common.RequestIdKey)
otherStr := common.MapToJsonStr(params.Other)
// 判断是否需要记录 IP
needRecordIp := false
@@ -191,7 +194,8 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
return ""
}(),
Other: otherStr,
RequestId: requestId,
Other: otherStr,
}
err := LOG_DB.Create(log).Error
if err != nil {
@@ -204,7 +208,7 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string) (logs []*Log, total int64, err error) {
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB
@@ -221,6 +225,9 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}
@@ -269,7 +276,7 @@ func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName
return logs, total, err
}
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string) (logs []*Log, total int64, err error) {
func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int64, modelName string, tokenName string, startIdx int, num int, group string, requestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {
tx = LOG_DB.Where("logs.user_id = ?", userId)
@@ -283,6 +290,9 @@ func GetUserLogs(userId int, logType int, startTimestamp int64, endTimestamp int
if tokenName != "" {
tx = tx.Where("logs.token_name = ?", tokenName)
}
if requestId != "" {
tx = tx.Where("logs.request_id = ?", requestId)
}
if startTimestamp != 0 {
tx = tx.Where("logs.created_at >= ?", startTimestamp)
}

View File

@@ -248,6 +248,9 @@ func InitLogDB() (err error) {
}
func migrateDB() error {
// Migrate price_amount column from float/double to decimal for existing tables
migrateSubscriptionPlanPriceAmount()
err := DB.AutoMigrate(
&Channel{},
&Token{},
@@ -268,7 +271,6 @@ func migrateDB() error {
&TwoFA{},
&TwoFABackupCode{},
&Checkin{},
&SubscriptionPlan{},
&SubscriptionOrder{},
&UserSubscription{},
&SubscriptionPreConsumeRecord{},
@@ -276,6 +278,15 @@ func migrateDB() error {
if err != nil {
return err
}
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
}
} else {
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
return err
}
}
return nil
}
@@ -306,7 +317,6 @@ func migrateDBFast() error {
{&TwoFA{}, "TwoFA"},
{&TwoFABackupCode{}, "TwoFABackupCode"},
{&Checkin{}, "Checkin"},
{&SubscriptionPlan{}, "SubscriptionPlan"},
{&SubscriptionOrder{}, "SubscriptionOrder"},
{&UserSubscription{}, "UserSubscription"},
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
@@ -334,6 +344,15 @@ func migrateDBFast() error {
return err
}
}
if common.UsingSQLite {
if err := ensureSubscriptionPlanTableSQLite(); err != nil {
return err
}
} else {
if err := DB.AutoMigrate(&SubscriptionPlan{}); err != nil {
return err
}
}
common.SysLog("database migrated")
return nil
}
@@ -346,6 +365,139 @@ func migrateLOGDB() error {
return nil
}
type sqliteColumnDef struct {
Name string
DDL string
}
func ensureSubscriptionPlanTableSQLite() error {
if !common.UsingSQLite {
return nil
}
tableName := "subscription_plans"
if !DB.Migrator().HasTable(tableName) {
createSQL := `CREATE TABLE ` + "`" + tableName + "`" + ` (
` + "`id`" + ` integer,
` + "`title`" + ` varchar(128) NOT NULL,
` + "`subtitle`" + ` varchar(255) DEFAULT '',
` + "`price_amount`" + ` decimal(10,6) NOT NULL,
` + "`currency`" + ` varchar(8) NOT NULL DEFAULT 'USD',
` + "`duration_unit`" + ` varchar(16) NOT NULL DEFAULT 'month',
` + "`duration_value`" + ` integer NOT NULL DEFAULT 1,
` + "`custom_seconds`" + ` bigint NOT NULL DEFAULT 0,
` + "`enabled`" + ` numeric DEFAULT 1,
` + "`sort_order`" + ` integer DEFAULT 0,
` + "`stripe_price_id`" + ` varchar(128) DEFAULT '',
` + "`creem_product_id`" + ` varchar(128) DEFAULT '',
` + "`max_purchase_per_user`" + ` integer DEFAULT 0,
` + "`upgrade_group`" + ` varchar(64) DEFAULT '',
` + "`total_amount`" + ` bigint NOT NULL DEFAULT 0,
` + "`quota_reset_period`" + ` varchar(16) DEFAULT 'never',
` + "`quota_reset_custom_seconds`" + ` bigint DEFAULT 0,
` + "`created_at`" + ` bigint,
` + "`updated_at`" + ` bigint,
PRIMARY KEY (` + "`id`" + `)
)`
return DB.Exec(createSQL).Error
}
var cols []struct {
Name string `gorm:"column:name"`
}
if err := DB.Raw("PRAGMA table_info(`" + tableName + "`)").Scan(&cols).Error; err != nil {
return err
}
existing := make(map[string]struct{}, len(cols))
for _, c := range cols {
existing[c.Name] = struct{}{}
}
required := []sqliteColumnDef{
{Name: "title", DDL: "`title` varchar(128) NOT NULL"},
{Name: "subtitle", DDL: "`subtitle` varchar(255) DEFAULT ''"},
{Name: "price_amount", DDL: "`price_amount` decimal(10,6) NOT NULL"},
{Name: "currency", DDL: "`currency` varchar(8) NOT NULL DEFAULT 'USD'"},
{Name: "duration_unit", DDL: "`duration_unit` varchar(16) NOT NULL DEFAULT 'month'"},
{Name: "duration_value", DDL: "`duration_value` integer NOT NULL DEFAULT 1"},
{Name: "custom_seconds", DDL: "`custom_seconds` bigint NOT NULL DEFAULT 0"},
{Name: "enabled", DDL: "`enabled` numeric DEFAULT 1"},
{Name: "sort_order", DDL: "`sort_order` integer DEFAULT 0"},
{Name: "stripe_price_id", DDL: "`stripe_price_id` varchar(128) DEFAULT ''"},
{Name: "creem_product_id", DDL: "`creem_product_id` varchar(128) DEFAULT ''"},
{Name: "max_purchase_per_user", DDL: "`max_purchase_per_user` integer DEFAULT 0"},
{Name: "upgrade_group", DDL: "`upgrade_group` varchar(64) DEFAULT ''"},
{Name: "total_amount", DDL: "`total_amount` bigint NOT NULL DEFAULT 0"},
{Name: "quota_reset_period", DDL: "`quota_reset_period` varchar(16) DEFAULT 'never'"},
{Name: "quota_reset_custom_seconds", DDL: "`quota_reset_custom_seconds` bigint DEFAULT 0"},
{Name: "created_at", DDL: "`created_at` bigint"},
{Name: "updated_at", DDL: "`updated_at` bigint"},
}
for _, col := range required {
if _, ok := existing[col.Name]; ok {
continue
}
if err := DB.Exec("ALTER TABLE `" + tableName + "` ADD COLUMN " + col.DDL).Error; err != nil {
return err
}
}
return nil
}
// migrateSubscriptionPlanPriceAmount migrates price_amount column from float/double to decimal(10,6)
// This is safe to run multiple times - it checks the column type first
func migrateSubscriptionPlanPriceAmount() {
// SQLite doesn't support ALTER COLUMN, and its type affinity handles this automatically
// Skip early to avoid GORM parsing the existing table DDL which may cause issues
if common.UsingSQLite {
return
}
tableName := "subscription_plans"
columnName := "price_amount"
// Check if table exists first
if !DB.Migrator().HasTable(tableName) {
return
}
// Check if column exists
if !DB.Migrator().HasColumn(&SubscriptionPlan{}, columnName) {
return
}
var alterSQL string
if common.UsingPostgreSQL {
// PostgreSQL: Check if already decimal/numeric
var dataType string
DB.Raw(`SELECT data_type FROM information_schema.columns
WHERE table_name = ? AND column_name = ?`, tableName, columnName).Scan(&dataType)
if dataType == "numeric" {
return // Already decimal/numeric
}
alterSQL = fmt.Sprintf(`ALTER TABLE %s ALTER COLUMN %s TYPE decimal(10,6) USING %s::decimal(10,6)`,
tableName, columnName, columnName)
} else if common.UsingMySQL {
// MySQL: Check if already decimal
var columnType string
DB.Raw(`SELECT COLUMN_TYPE FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ?`,
tableName, columnName).Scan(&columnType)
if strings.HasPrefix(strings.ToLower(columnType), "decimal") {
return // Already decimal
}
alterSQL = fmt.Sprintf("ALTER TABLE %s MODIFY COLUMN %s decimal(10,6) NOT NULL DEFAULT 0",
tableName, columnName)
} else {
return
}
if alterSQL != "" {
if err := DB.Exec(alterSQL).Error; err != nil {
common.SysLog(fmt.Sprintf("Warning: failed to migrate %s.%s to decimal: %v", tableName, columnName, err))
} else {
common.SysLog(fmt.Sprintf("Successfully migrated %s.%s to decimal(10,6)", tableName, columnName))
}
}
}
func closeDB(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {

View File

@@ -148,7 +148,8 @@ func Redeem(key string, userId int) (quota int, err error) {
return err
})
if err != nil {
return 0, errors.New("兑换失败," + err.Error())
common.SysError("redemption failed: " + err.Error())
return 0, errors.New("兑换失败,请稍后重试")
}
RecordLog(userId, LogTypeTopup, fmt.Sprintf("通过兑换码充值 %s兑换码ID %d", logger.LogQuota(redemption.Quota), redemption.Id))
return redemption.Quota, nil

View File

@@ -149,7 +149,7 @@ type SubscriptionPlan struct {
Subtitle string `json:"subtitle" gorm:"type:varchar(255);default:''"`
// Display money amount (follow existing code style: float64 for money)
PriceAmount float64 `json:"price_amount" gorm:"type:double;not null;default:0"`
PriceAmount float64 `json:"price_amount" gorm:"type:decimal(10,6);not null;default:0"`
Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'USD'"`
DurationUnit string `json:"duration_unit" gorm:"type:varchar(16);not null;default:'month'"`

View File

@@ -57,6 +57,7 @@ type Task struct {
FinishTime int64 `json:"finish_time" gorm:"index"`
Progress string `json:"progress" gorm:"type:varchar(20);index"`
Properties Properties `json:"properties" gorm:"type:json"`
Username string `json:"username,omitempty" gorm:"-"`
// 禁止返回给用户内部可能包含key等隐私信息
PrivateData TaskPrivateData `json:"-" gorm:"column:private_data;type:json"`
Data json.RawMessage `json:"data" gorm:"type:json"`
@@ -233,6 +234,12 @@ func TaskGetAllTasks(startIdx int, num int, queryParams SyncTaskQueryParams) []*
return nil
}
for _, task := range tasks {
if cache, err := GetUserCache(task.UserId); err == nil {
task.Username = cache.Username
}
}
return tasks
}

View File

@@ -95,7 +95,8 @@ func Recharge(referenceId string, customerId string) (err error) {
})
if err != nil {
return errors.New("充值失败," + err.Error())
common.SysError("topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用在线充值成功,充值金额: %v支付金额%d", logger.FormatQuota(int(quota)), topUp.Amount))
@@ -367,7 +368,8 @@ func RechargeCreem(referenceId string, customerEmail string, customerName string
})
if err != nil {
return errors.New("充值失败," + err.Error())
common.SysError("creem topup failed: " + err.Error())
return errors.New("充值失败,请稍后重试")
}
RecordLog(topUp.UserId, LogTypeTopup, fmt.Sprintf("使用Creem充值成功充值额度: %v支付金额%.2f", quota, topUp.Money))

View File

@@ -49,12 +49,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
for i2, mediaMessage := range content {
if mediaMessage.Source != nil {
if mediaMessage.Source.Type == "url" {
fileData, err := service.GetFileBase64FromUrl(c, mediaMessage.Source.Url, "formatting image for Claude")
// 使用统一的文件服务获取图片数据
source := types.NewURLFileSource(mediaMessage.Source.Url)
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
}
mediaMessage.Source.MediaType = fileData.MimeType
mediaMessage.Source.Data = fileData.Base64Data
mediaMessage.Source.MediaType = mimeType
mediaMessage.Source.Data = base64Data
mediaMessage.Source.Url = ""
mediaMessage.Source.Type = "base64"
content[i2] = mediaMessage

View File

@@ -364,23 +364,19 @@ func RequestOpenAI2ClaudeMessage(c *gin.Context, textRequest dto.GeneralOpenAIRe
claudeMediaMessage.Source = &dto.ClaudeMessageSource{
Type: "base64",
}
// 判断是否是url
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(imageUrl.Url, "http") {
// 是url获取图片的类型和base64编码的数据
fileData, err := service.GetFileBase64FromUrl(c, imageUrl.Url, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file base64 from url failed: %s", err.Error())
}
claudeMediaMessage.Source.MediaType = fileData.MimeType
claudeMediaMessage.Source.Data = fileData.Base64Data
source = types.NewURLFileSource(imageUrl.Url)
} else {
_, format, base64String, err := service.DecodeBase64ImageData(imageUrl.Url)
if err != nil {
return nil, err
}
claudeMediaMessage.Source.MediaType = "image/" + format
claudeMediaMessage.Source.Data = base64String
source = types.NewBase64FileSource(imageUrl.Url, "")
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Claude")
if err != nil {
return nil, fmt.Errorf("get file data failed: %s", err.Error())
}
claudeMediaMessage.Source.MediaType = mimeType
claudeMediaMessage.Source.Data = base64Data
}
claudeMediaMessages = append(claudeMediaMessages, claudeMediaMessage)
}

View File

@@ -466,7 +466,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
}
openaiContent := message.ParseContent()
imageNum := 0
for _, part := range openaiContent {
if part.Type == dto.ContentTypeText {
if part.Text == "" {
@@ -507,10 +506,6 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
}
// 提取 data URL (从 "](" 后面开始,到 ")" 之前)
dataUrl := text[bracketIdx+2 : closeIdx]
imageNum += 1
if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum {
return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum)
}
format, base64String, err := service.DecodeBase64FileData(dataUrl)
if err != nil {
return nil, fmt.Errorf("decode markdown base64 image data failed: %s", err.Error())
@@ -535,69 +530,58 @@ func CovertOpenAI2Gemini(c *gin.Context, textRequest dto.GeneralOpenAIRequest, i
})
}
} else if part.Type == dto.ContentTypeImageURL {
imageNum += 1
if constant.GeminiVisionMaxImageNum != -1 && imageNum > constant.GeminiVisionMaxImageNum {
return nil, fmt.Errorf("too many images in the message, max allowed is %d", constant.GeminiVisionMaxImageNum)
}
// 判断是否是url
if strings.HasPrefix(part.GetImageMedia().Url, "http") {
// 是url获取文件的类型和base64编码的数据
fileData, err := service.GetFileBase64FromUrl(c, part.GetImageMedia().Url, "formatting image for Gemini")
if err != nil {
return nil, fmt.Errorf("get file base64 from url '%s' failed: %w", part.GetImageMedia().Url, err)
}
// 校验 MimeType 是否在 Gemini 支持的白名单中
if _, ok := geminiSupportedMimeTypes[strings.ToLower(fileData.MimeType)]; !ok {
url := part.GetImageMedia().Url
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", fileData.MimeType, url, getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: fileData.MimeType, // 使用原始的 MimeType因为大小写可能对API有意义
Data: fileData.Base64Data,
},
})
// 使用统一的文件服务获取图片数据
var source *types.FileSource
imageUrl := part.GetImageMedia().Url
if strings.HasPrefix(imageUrl, "http") {
source = types.NewURLFileSource(imageUrl)
} else {
format, base64String, err := service.DecodeBase64FileData(part.GetImageMedia().Url)
if err != nil {
return nil, fmt.Errorf("decode base64 image data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
},
})
source = types.NewBase64FileSource(imageUrl, "")
}
base64Data, mimeType, err := service.GetBase64Data(c, source, "formatting image for Gemini")
if err != nil {
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
}
// 校验 MimeType 是否在 Gemini 支持的白名单中
if _, ok := geminiSupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), getSupportedMimeTypesList())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeFile {
if part.GetFile().FileId != "" {
return nil, fmt.Errorf("only base64 file is supported in gemini")
}
format, base64String, err := service.DecodeBase64FileData(part.GetFile().FileData)
fileSource := types.NewBase64FileSource(part.GetFile().FileData, "")
base64Data, mimeType, err := service.GetBase64Data(c, fileSource, "formatting file for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 file data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: format,
Data: base64String,
MimeType: mimeType,
Data: base64Data,
},
})
} else if part.Type == dto.ContentTypeInputAudio {
if part.GetInputAudio().Data == "" {
return nil, fmt.Errorf("only base64 audio is supported in gemini")
}
base64String, err := service.DecodeBase64AudioData(part.GetInputAudio().Data)
audioSource := types.NewBase64FileSource(part.GetInputAudio().Data, "audio/"+part.GetInputAudio().Format)
base64Data, mimeType, err := service.GetBase64Data(c, audioSource, "formatting audio for Gemini")
if err != nil {
return nil, fmt.Errorf("decode base64 audio data failed: %s", err.Error())
}
parts = append(parts, dto.GeminiPart{
InlineData: &dto.GeminiInlineData{
MimeType: "audio/" + part.GetInputAudio().Format,
Data: base64String,
MimeType: mimeType,
Data: base64Data,
},
})
}
@@ -988,11 +972,9 @@ func unescapeMapOrSlice(data interface{}) interface{} {
func getResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
var argsBytes []byte
var err error
if result, ok := item.FunctionCall.Arguments.(map[string]interface{}); ok {
argsBytes, err = json.Marshal(unescapeMapOrSlice(result))
} else {
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
}
// 移除 unescapeMapOrSlice 调用,直接使用 json.Marshal
// JSON 序列化/反序列化已经正确处理了转义字符
argsBytes, err = json.Marshal(item.FunctionCall.Arguments)
if err != nil {
return nil

View File

@@ -99,19 +99,16 @@ func openAIChatToOllamaChat(c *gin.Context, r *dto.GeneralOpenAIRequest) (*Ollam
if part.Type == dto.ContentTypeImageURL {
img := part.GetImageMedia()
if img != nil && img.Url != "" {
var base64Data string
// 使用统一的文件服务获取图片数据
var source *types.FileSource
if strings.HasPrefix(img.Url, "http") {
fileData, err := service.GetFileBase64FromUrl(c, img.Url, "fetch image for ollama chat")
if err != nil {
return nil, err
}
base64Data = fileData.Base64Data
} else if strings.HasPrefix(img.Url, "data:") {
if idx := strings.Index(img.Url, ","); idx != -1 && idx+1 < len(img.Url) {
base64Data = img.Url[idx+1:]
}
source = types.NewURLFileSource(img.Url)
} else {
base64Data = img.Url
source = types.NewBase64FileSource(img.Url, "")
}
base64Data, _, err := service.GetBase64Data(c, source, "fetch image for ollama chat")
if err != nil {
return nil, err
}
if base64Data != "" {
images = append(images, base64Data)

View File

@@ -59,6 +59,7 @@ func SetApiRouter(router *gin.Engine) {
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
userRoute.GET("/logout", controller.Logout)
userRoute.POST("/epay/notify", controller.EpayNotify)
userRoute.GET("/epay/notify", controller.EpayNotify)
userRoute.GET("/groups", controller.GetUserGroups)
selfRoute := userRoute.Group("/")
@@ -149,6 +150,7 @@ func SetApiRouter(router *gin.Engine) {
// Subscription payment callbacks (no auth)
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
optionRoute := apiRouter.Group("/option")

View File

@@ -57,11 +57,13 @@ func SetRelayRouter(router *gin.Engine) {
}
playgroundRouter := router.Group("/pg")
playgroundRouter.Use(middleware.SystemPerformanceCheck())
playgroundRouter.Use(middleware.UserAuth(), middleware.Distribute())
{
playgroundRouter.POST("/chat/completions", controller.Playground)
}
relayV1Router := router.Group("/v1")
relayV1Router.Use(middleware.SystemPerformanceCheck())
relayV1Router.Use(middleware.TokenAuth())
relayV1Router.Use(middleware.ModelRequestRateLimit())
{
@@ -159,13 +161,16 @@ func SetRelayRouter(router *gin.Engine) {
}
relayMjRouter := router.Group("/mj")
relayMjRouter.Use(middleware.SystemPerformanceCheck())
registerMjRouterGroup(relayMjRouter)
relayMjModeRouter := router.Group("/:mode/mj")
relayMjModeRouter.Use(middleware.SystemPerformanceCheck())
registerMjRouterGroup(relayMjModeRouter)
//relayMjRouter.Use()
relaySunoRouter := router.Group("/suno")
relaySunoRouter.Use(middleware.SystemPerformanceCheck())
relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute())
{
relaySunoRouter.POST("/submit/:action", controller.RelayTask)
@@ -174,6 +179,7 @@ func SetRelayRouter(router *gin.Engine) {
}
relayGeminiRouter := router.Group("/v1beta")
relayGeminiRouter.Use(middleware.SystemPerformanceCheck())
relayGeminiRouter.Use(middleware.TokenAuth())
relayGeminiRouter.Use(middleware.ModelRequestRateLimit())
relayGeminiRouter.Use(middleware.Distribute())

View File

@@ -2,7 +2,6 @@ package service
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
@@ -13,7 +12,6 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/types"
@@ -130,90 +128,27 @@ func GetFileTypeFromUrl(c *gin.Context, url string, reason ...string) (string, e
return "application/octet-stream", nil
}
// GetFileBase64FromUrl 从 URL 获取文件的 base64 编码数据
// Deprecated: 请使用 GetBase64Data 配合 types.NewURLFileSource 替代
// 此函数保留用于向后兼容,内部已重构为调用统一的文件服务
func GetFileBase64FromUrl(c *gin.Context, url string, reason ...string) (*types.LocalFileData, error) {
contextKey := fmt.Sprintf("file_download_%s", common.GenerateHMAC(url))
// Check if the file has already been downloaded in this request
if cachedData, exists := c.Get(contextKey); exists {
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("Using cached file data for URL: %s", url))
}
return cachedData.(*types.LocalFileData), nil
}
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
resp, err := DoDownloadRequest(url, reason...)
source := types.NewURLFileSource(url)
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Always use LimitReader to prevent oversized downloads
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
// 转换为旧的 LocalFileData 格式以保持兼容
base64Data, err := cachedData.GetBase64Data()
if err != nil {
return nil, err
}
// Check actual size after reading
if len(fileBytes) > maxFileSize {
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
}
// Convert to base64
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
mimeType := resp.Header.Get("Content-Type")
if len(strings.Split(mimeType, ";")) > 1 {
// If Content-Type has parameters, take the first part
mimeType = strings.Split(mimeType, ";")[0]
}
if mimeType == "application/octet-stream" {
logger.LogDebug(c, fmt.Sprintf("MIME type is application/octet-stream for URL: %s", url))
// try to guess the MIME type from the url last segment
urlParts := strings.Split(url, "/")
if len(urlParts) > 0 {
lastSegment := urlParts[len(urlParts)-1]
if strings.Contains(lastSegment, ".") {
// Extract the file extension
filename := strings.Split(lastSegment, ".")
if len(filename) > 1 {
ext := strings.ToLower(filename[len(filename)-1])
// Guess MIME type based on file extension
mimeType = GetMimeTypeByExtension(ext)
}
}
} else {
// try to guess the MIME type from the file extension
fileName := resp.Header.Get("Content-Disposition")
if fileName != "" {
// Extract the filename from the Content-Disposition header
parts := strings.Split(fileName, ";")
for _, part := range parts {
if strings.HasPrefix(strings.TrimSpace(part), "filename=") {
fileName = strings.TrimSpace(strings.TrimPrefix(part, "filename="))
// Remove quotes if present
if len(fileName) > 2 && fileName[0] == '"' && fileName[len(fileName)-1] == '"' {
fileName = fileName[1 : len(fileName)-1]
}
// Guess MIME type based on file extension
if ext := strings.ToLower(strings.TrimPrefix(fileName, ".")); ext != "" {
mimeType = GetMimeTypeByExtension(ext)
}
break
}
}
}
}
}
data := &types.LocalFileData{
return &types.LocalFileData{
Base64Data: base64Data,
MimeType: mimeType,
Size: int64(len(fileBytes)),
}
// Store the file data in the context to avoid re-downloading
c.Set(contextKey, data)
return data, nil
MimeType: cachedData.MimeType,
Size: cachedData.Size,
Url: url,
}, nil
}
func GetMimeTypeByExtension(ext string) string {

471
service/file_service.go Normal file
View File

@@ -0,0 +1,471 @@
package service
import (
"bytes"
"encoding/base64"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"golang.org/x/image/webp"
)
// FileService 统一的文件处理服务
// 提供文件下载、解码、缓存等功能的统一入口
// getContextCacheKey 生成 context 缓存的 key
func getContextCacheKey(url string) string {
return fmt.Sprintf("file_cache_%s", common.GenerateHMAC(url))
}
// LoadFileSource 加载文件源数据
// 这是统一的入口,会自动处理缓存和不同的来源类型
func LoadFileSource(c *gin.Context, source *types.FileSource, reason ...string) (*types.CachedFileData, error) {
if source == nil {
return nil, fmt.Errorf("file source is nil")
}
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("LoadFileSource starting for: %s", source.GetIdentifier()))
}
// 1. 快速检查内部缓存
if source.HasCache() {
// 即使命中内部缓存,也要确保注册到清理列表(如果尚未注册)
if c != nil {
registerSourceForCleanup(c, source)
}
return source.GetCache(), nil
}
// 2. 加锁保护加载过程
source.Mu().Lock()
defer source.Mu().Unlock()
// 3. 双重检查
if source.HasCache() {
if c != nil {
registerSourceForCleanup(c, source)
}
return source.GetCache(), nil
}
// 4. 如果是 URL检查 Context 缓存
var contextKey string
if source.IsURL() && c != nil {
contextKey = getContextCacheKey(source.URL)
if cachedData, exists := c.Get(contextKey); exists {
data := cachedData.(*types.CachedFileData)
source.SetCache(data)
registerSourceForCleanup(c, source)
return data, nil
}
}
// 5. 执行加载逻辑
var cachedData *types.CachedFileData
var err error
if source.IsURL() {
cachedData, err = loadFromURL(c, source.URL, reason...)
} else {
cachedData, err = loadFromBase64(source.Base64Data, source.MimeType)
}
if err != nil {
return nil, err
}
// 6. 设置缓存
source.SetCache(cachedData)
if contextKey != "" && c != nil {
c.Set(contextKey, cachedData)
}
// 7. 注册到 context 以便请求结束时自动清理
if c != nil {
registerSourceForCleanup(c, source)
}
return cachedData, nil
}
// registerSourceForCleanup 注册 FileSource 到 context 以便请求结束时清理
func registerSourceForCleanup(c *gin.Context, source *types.FileSource) {
if source.IsRegistered() {
return
}
key := string(constant.ContextKeyFileSourcesToCleanup)
var sources []*types.FileSource
if existing, exists := c.Get(key); exists {
sources = existing.([]*types.FileSource)
}
sources = append(sources, source)
c.Set(key, sources)
source.SetRegistered(true)
}
// CleanupFileSources 清理请求中所有注册的 FileSource
// 应在请求结束时调用(通常由中间件自动调用)
func CleanupFileSources(c *gin.Context) {
key := string(constant.ContextKeyFileSourcesToCleanup)
if sources, exists := c.Get(key); exists {
for _, source := range sources.([]*types.FileSource) {
if cache := source.GetCache(); cache != nil {
cache.Close()
}
}
c.Set(key, nil) // 清除引用
}
}
// loadFromURL 从 URL 加载文件
func loadFromURL(c *gin.Context, url string, reason ...string) (*types.CachedFileData, error) {
// 下载文件
var maxFileSize = constant.MaxFileDownloadMB * 1024 * 1024
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: initiating download")
}
resp, err := DoDownloadRequest(url, reason...)
if err != nil {
return nil, fmt.Errorf("failed to download file from %s: %w", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to download file, status code: %d", resp.StatusCode)
}
// 读取文件内容(限制大小)
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: reading response body")
}
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxFileSize+1)))
if err != nil {
return nil, fmt.Errorf("failed to read file content: %w", err)
}
if len(fileBytes) > maxFileSize {
return nil, fmt.Errorf("file size exceeds maximum allowed size: %dMB", constant.MaxFileDownloadMB)
}
// 转换为 base64
base64Data := base64.StdEncoding.EncodeToString(fileBytes)
// 智能获取 MIME 类型
mimeType := smartDetectMimeType(resp, url, fileBytes)
// 判断是否使用磁盘缓存
base64Size := int64(len(base64Data))
var cachedData *types.CachedFileData
if shouldUseDiskCache(base64Size) {
// 使用磁盘缓存
diskPath, err := writeToDiskCache(base64Data)
if err != nil {
// 磁盘缓存失败,回退到内存
logger.LogWarn(c, fmt.Sprintf("Failed to write to disk cache, falling back to memory: %v", err))
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
} else {
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(fileBytes)))
cachedData.DiskSize = base64Size
cachedData.OnClose = func(size int64) {
common.DecrementDiskFiles(size)
}
common.IncrementDiskFiles(base64Size)
if common.DebugEnabled {
logger.LogDebug(c, fmt.Sprintf("File cached to disk: %s, size: %d bytes", diskPath, base64Size))
}
}
} else {
// 使用内存缓存
cachedData = types.NewMemoryCachedData(base64Data, mimeType, int64(len(fileBytes)))
}
// 如果是图片,尝试获取图片配置
if strings.HasPrefix(mimeType, "image/") {
if common.DebugEnabled {
logger.LogDebug(c, "loadFromURL: decoding image config")
}
config, format, err := decodeImageConfig(fileBytes)
if err == nil {
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
// 如果通过图片解码获取了更准确的格式,更新 MIME 类型
if mimeType == "application/octet-stream" || mimeType == "" {
cachedData.MimeType = "image/" + format
}
}
}
return cachedData, nil
}
// shouldUseDiskCache 判断是否应该使用磁盘缓存
func shouldUseDiskCache(dataSize int64) bool {
return common.ShouldUseDiskCache(dataSize)
}
// writeToDiskCache 将数据写入磁盘缓存
func writeToDiskCache(base64Data string) (string, error) {
return common.WriteDiskCacheFileString(common.DiskCacheTypeFile, base64Data)
}
// smartDetectMimeType 智能检测 MIME 类型
func smartDetectMimeType(resp *http.Response, url string, fileBytes []byte) string {
// 1. 尝试从 Content-Type header 获取
mimeType := resp.Header.Get("Content-Type")
if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx])
}
if mimeType != "" && mimeType != "application/octet-stream" {
return mimeType
}
// 2. 尝试从 Content-Disposition header 的 filename 获取
if cd := resp.Header.Get("Content-Disposition"); cd != "" {
parts := strings.Split(cd, ";")
for _, part := range parts {
part = strings.TrimSpace(part)
if strings.HasPrefix(strings.ToLower(part), "filename=") {
name := strings.TrimSpace(strings.TrimPrefix(part, "filename="))
// 移除引号
if len(name) > 2 && name[0] == '"' && name[len(name)-1] == '"' {
name = name[1 : len(name)-1]
}
if dot := strings.LastIndex(name, "."); dot != -1 && dot+1 < len(name) {
ext := strings.ToLower(name[dot+1:])
if ext != "" {
mt := GetMimeTypeByExtension(ext)
if mt != "application/octet-stream" {
return mt
}
}
}
break
}
}
}
// 3. 尝试从 URL 路径获取扩展名
mt := guessMimeTypeFromURL(url)
if mt != "application/octet-stream" {
return mt
}
// 4. 使用 http.DetectContentType 内容嗅探
if len(fileBytes) > 0 {
sniffed := http.DetectContentType(fileBytes)
if sniffed != "" && sniffed != "application/octet-stream" {
// 去除可能的 charset 参数
if idx := strings.Index(sniffed, ";"); idx != -1 {
sniffed = strings.TrimSpace(sniffed[:idx])
}
return sniffed
}
}
// 5. 尝试作为图片解码获取格式
if len(fileBytes) > 0 {
if _, format, err := decodeImageConfig(fileBytes); err == nil && format != "" {
return "image/" + strings.ToLower(format)
}
}
// 最终回退
return "application/octet-stream"
}
// loadFromBase64 从 base64 字符串加载文件
func loadFromBase64(base64String string, providedMimeType string) (*types.CachedFileData, error) {
var mimeType string
var cleanBase64 string
// 处理 data: 前缀
if strings.HasPrefix(base64String, "data:") {
idx := strings.Index(base64String, ",")
if idx != -1 {
header := base64String[:idx]
cleanBase64 = base64String[idx+1:]
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mimeType = header[mimeStart:mimeEnd]
}
}
} else {
cleanBase64 = base64String
}
} else {
cleanBase64 = base64String
}
if providedMimeType != "" {
mimeType = providedMimeType
}
decodedData, err := base64.StdEncoding.DecodeString(cleanBase64)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
}
base64Size := int64(len(cleanBase64))
var cachedData *types.CachedFileData
if shouldUseDiskCache(base64Size) {
diskPath, err := writeToDiskCache(cleanBase64)
if err != nil {
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
} else {
cachedData = types.NewDiskCachedData(diskPath, mimeType, int64(len(decodedData)))
cachedData.DiskSize = base64Size
cachedData.OnClose = func(size int64) {
common.DecrementDiskFiles(size)
}
common.IncrementDiskFiles(base64Size)
}
} else {
cachedData = types.NewMemoryCachedData(cleanBase64, mimeType, int64(len(decodedData)))
}
if mimeType == "" || strings.HasPrefix(mimeType, "image/") {
config, format, err := decodeImageConfig(decodedData)
if err == nil {
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
if mimeType == "" {
cachedData.MimeType = "image/" + format
}
}
}
return cachedData, nil
}
// GetImageConfig 获取图片配置
func GetImageConfig(c *gin.Context, source *types.FileSource) (image.Config, string, error) {
cachedData, err := LoadFileSource(c, source, "get_image_config")
if err != nil {
return image.Config{}, "", err
}
if cachedData.ImageConfig != nil {
return *cachedData.ImageConfig, cachedData.ImageFormat, nil
}
base64Str, err := cachedData.GetBase64Data()
if err != nil {
return image.Config{}, "", fmt.Errorf("failed to get base64 data: %w", err)
}
decodedData, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return image.Config{}, "", fmt.Errorf("failed to decode base64 for image config: %w", err)
}
config, format, err := decodeImageConfig(decodedData)
if err != nil {
return image.Config{}, "", err
}
cachedData.ImageConfig = &config
cachedData.ImageFormat = format
return config, format, nil
}
// GetBase64Data 获取 base64 编码的数据
func GetBase64Data(c *gin.Context, source *types.FileSource, reason ...string) (string, string, error) {
cachedData, err := LoadFileSource(c, source, reason...)
if err != nil {
return "", "", err
}
base64Str, err := cachedData.GetBase64Data()
if err != nil {
return "", "", fmt.Errorf("failed to get base64 data: %w", err)
}
return base64Str, cachedData.MimeType, nil
}
// GetMimeType 获取文件的 MIME 类型
func GetMimeType(c *gin.Context, source *types.FileSource) (string, error) {
if source.HasCache() {
return source.GetCache().MimeType, nil
}
if source.IsURL() {
mimeType, err := GetFileTypeFromUrl(c, source.URL, "get_mime_type")
if err == nil && mimeType != "" && mimeType != "application/octet-stream" {
return mimeType, nil
}
}
cachedData, err := LoadFileSource(c, source, "get_mime_type")
if err != nil {
return "", err
}
return cachedData.MimeType, nil
}
// DetectFileType 检测文件类型
func DetectFileType(mimeType string) types.FileType {
if strings.HasPrefix(mimeType, "image/") {
return types.FileTypeImage
}
if strings.HasPrefix(mimeType, "audio/") {
return types.FileTypeAudio
}
if strings.HasPrefix(mimeType, "video/") {
return types.FileTypeVideo
}
return types.FileTypeFile
}
// decodeImageConfig 从字节数据解码图片配置
func decodeImageConfig(data []byte) (image.Config, string, error) {
reader := bytes.NewReader(data)
config, format, err := image.DecodeConfig(reader)
if err == nil {
return config, format, nil
}
reader.Seek(0, io.SeekStart)
config, err = webp.DecodeConfig(reader)
if err == nil {
return config, "webp", nil
}
return image.Config{}, "", fmt.Errorf("failed to decode image config: unsupported format")
}
// guessMimeTypeFromURL 从 URL 猜测 MIME 类型
func guessMimeTypeFromURL(url string) string {
cleanedURL := url
if q := strings.Index(cleanedURL, "?"); q != -1 {
cleanedURL = cleanedURL[:q]
}
if slash := strings.LastIndex(cleanedURL, "/"); slash != -1 && slash+1 < len(cleanedURL) {
last := cleanedURL[slash+1:]
if dot := strings.LastIndex(last, "."); dot != -1 && dot+1 < len(last) {
ext := strings.ToLower(last[dot+1:])
return GetMimeTypeByExtension(ext)
}
}
return "application/octet-stream"
}

View File

@@ -3,10 +3,6 @@ package service
import (
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"log"
"math"
"path/filepath"
@@ -23,8 +19,8 @@ import (
"github.com/gin-gonic/gin"
)
func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, error) {
if fileMeta == nil {
func getImageToken(c *gin.Context, fileMeta *types.FileMeta, model string, stream bool) (int, error) {
if fileMeta == nil || fileMeta.Source == nil {
return 0, fmt.Errorf("image_url_is_nil")
}
@@ -99,35 +95,20 @@ func getImageToken(fileMeta *types.FileMeta, model string, stream bool) (int, er
fileMeta.Detail = "high"
}
// Decode image to get dimensions
var config image.Config
var err error
var format string
var b64str string
if fileMeta.ParsedData != nil {
config, format, b64str, err = DecodeBase64ImageData(fileMeta.ParsedData.Base64Data)
} else {
if strings.HasPrefix(fileMeta.OriginData, "http") {
config, format, err = DecodeUrlImageData(fileMeta.OriginData)
} else {
common.SysLog(fmt.Sprintf("decoding image"))
config, format, b64str, err = DecodeBase64ImageData(fileMeta.OriginData)
}
fileMeta.MimeType = format
}
// 使用统一的文件服务获取图片配置
config, format, err := GetImageConfig(c, fileMeta.Source)
if err != nil {
return 0, err
}
fileMeta.MimeType = format
if config.Width == 0 || config.Height == 0 {
// not an image
if format != "" && b64str != "" {
// not an image, but might be a valid file
if format != "" {
// file type
return 3 * baseTokens, nil
}
return 0, errors.New(fmt.Sprintf("fail to decode base64 config: %s", fileMeta.OriginData))
return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", fileMeta.GetIdentifier()))
}
width := config.Width
@@ -269,48 +250,26 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
shouldFetchFiles = false
}
// 使用统一的文件服务获取文件类型
for _, file := range meta.Files {
if strings.HasPrefix(file.OriginData, "http") {
if shouldFetchFiles {
mineType, err := GetFileTypeFromUrl(c, file.OriginData, "token_counter")
if err != nil {
return 0, fmt.Errorf("error getting file base64 from url: %v", err)
}
if strings.HasPrefix(mineType, "image/") {
file.FileType = types.FileTypeImage
} else if strings.HasPrefix(mineType, "video/") {
file.FileType = types.FileTypeVideo
} else if strings.HasPrefix(mineType, "audio/") {
file.FileType = types.FileTypeAudio
} else {
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
}
} else if strings.HasPrefix(file.OriginData, "data:") {
// get mime type from base64 header
parts := strings.SplitN(file.OriginData, ",", 2)
if len(parts) >= 1 {
header := parts[0]
// Extract mime type from "data:mime/type;base64" format
if strings.Contains(header, ":") && strings.Contains(header, ";") {
mimeStart := strings.Index(header, ":") + 1
mimeEnd := strings.Index(header, ";")
if mimeStart < mimeEnd {
mineType := header[mimeStart:mimeEnd]
if strings.HasPrefix(mineType, "image/") {
file.FileType = types.FileTypeImage
} else if strings.HasPrefix(mineType, "video/") {
file.FileType = types.FileTypeVideo
} else if strings.HasPrefix(mineType, "audio/") {
file.FileType = types.FileTypeAudio
} else {
file.FileType = types.FileTypeFile
}
file.MimeType = mineType
}
if file.Source == nil {
continue
}
// 如果文件类型未知且需要获取,通过 MIME 类型检测
if file.FileType == "" || (file.Source.IsURL() && shouldFetchFiles) {
// 注意:这里我们直接调用 LoadFileSource 而不是 GetMimeType
// 因为 GetMimeType 内部可能会调用 GetFileTypeFromUrl (HEAD 请求)
// 而我们这里既然要计算 token通常需要完整数据
cachedData, err := LoadFileSource(c, file.Source, "token_counter")
if err != nil {
if shouldFetchFiles {
return 0, fmt.Errorf("error getting file type: %v", err)
}
continue
}
file.MimeType = cachedData.MimeType
file.FileType = DetectFileType(cachedData.MimeType)
}
}
@@ -318,9 +277,9 @@ func EstimateRequestToken(c *gin.Context, meta *types.TokenCountMeta, info *rela
switch file.FileType {
case types.FileTypeImage:
if common.IsOpenAITextModel(model) {
token, err := getImageToken(file, model, info.IsStream)
token, err := getImageToken(c, file, model, info.IsStream)
if err != nil {
return 0, fmt.Errorf("error counting image token, media index[%d], original data[%s], err: %v", i, file.OriginData, err)
return 0, fmt.Errorf("error counting image token, media index[%d], identifier[%s], err: %v", i, file.GetIdentifier(), err)
}
tkm += token
} else {

View File

@@ -15,6 +15,15 @@ type PerformanceSetting struct {
DiskCacheMaxSizeMB int `json:"disk_cache_max_size_mb"`
// DiskCachePath 磁盘缓存目录
DiskCachePath string `json:"disk_cache_path"`
// MonitorEnabled 是否启用性能监控
MonitorEnabled bool `json:"monitor_enabled"`
// MonitorCPUThreshold CPU 使用率阈值(%
MonitorCPUThreshold int `json:"monitor_cpu_threshold"`
// MonitorMemoryThreshold 内存使用率阈值(%
MonitorMemoryThreshold int `json:"monitor_memory_threshold"`
// MonitorDiskThreshold 磁盘使用率阈值(%
MonitorDiskThreshold int `json:"monitor_disk_threshold"`
}
// 默认配置
@@ -23,6 +32,11 @@ var performanceSetting = PerformanceSetting{
DiskCacheThresholdMB: 10, // 超过 10MB 使用磁盘缓存
DiskCacheMaxSizeMB: 1024, // 最大 1GB 磁盘缓存
DiskCachePath: "", // 空表示使用系统临时目录
MonitorEnabled: true,
MonitorCPUThreshold: 90,
MonitorMemoryThreshold: 90,
MonitorDiskThreshold: 90,
}
func init() {
@@ -40,6 +54,13 @@ func syncToCommon() {
MaxSizeMB: performanceSetting.DiskCacheMaxSizeMB,
Path: performanceSetting.DiskCachePath,
})
common.SetPerformanceMonitorConfig(common.PerformanceMonitorConfig{
Enabled: performanceSetting.MonitorEnabled,
CPUThreshold: performanceSetting.MonitorCPUThreshold,
MemoryThreshold: performanceSetting.MonitorMemoryThreshold,
DiskThreshold: performanceSetting.MonitorDiskThreshold,
})
}
// GetPerformanceSetting 获取性能设置

231
types/file_source.go Normal file
View File

@@ -0,0 +1,231 @@
package types
import (
"fmt"
"image"
"os"
"sync"
)
// FileSourceType 文件来源类型
type FileSourceType string
const (
FileSourceTypeURL FileSourceType = "url" // URL 来源
FileSourceTypeBase64 FileSourceType = "base64" // Base64 内联数据
)
// FileSource 统一的文件来源抽象
// 支持 URL 和 base64 两种来源,提供懒加载和缓存机制
type FileSource struct {
Type FileSourceType `json:"type"` // 来源类型
URL string `json:"url,omitempty"` // URL当 Type 为 url 时)
Base64Data string `json:"base64_data,omitempty"` // Base64 数据(当 Type 为 base64 时)
MimeType string `json:"mime_type,omitempty"` // MIME 类型(可选,会自动检测)
// 内部缓存(不导出,不序列化)
cachedData *CachedFileData
cacheLoaded bool
registered bool // 是否已注册到清理列表
mu sync.Mutex // 保护加载过程
}
// Mu 获取内部锁
func (f *FileSource) Mu() *sync.Mutex {
return &f.mu
}
// CachedFileData 缓存的文件数据
// 支持内存缓存和磁盘缓存两种模式
type CachedFileData struct {
base64Data string // 内存中的 base64 数据(小文件)
MimeType string // MIME 类型
Size int64 // 文件大小(字节)
DiskSize int64 // 磁盘缓存实际占用大小(字节,通常是 base64 长度)
ImageConfig *image.Config // 图片配置(如果是图片)
ImageFormat string // 图片格式(如果是图片)
// 磁盘缓存相关
diskPath string // 磁盘缓存文件路径(大文件)
isDisk bool // 是否使用磁盘缓存
diskMu sync.Mutex // 磁盘操作锁(保护磁盘文件的读取和删除)
diskClosed bool // 是否已关闭/清理
statDecremented bool // 是否已扣减统计
// 统计回调,避免循环依赖
OnClose func(size int64)
}
// NewMemoryCachedData 创建内存缓存的数据
func NewMemoryCachedData(base64Data string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
base64Data: base64Data,
MimeType: mimeType,
Size: size,
isDisk: false,
}
}
// NewDiskCachedData 创建磁盘缓存的数据
func NewDiskCachedData(diskPath string, mimeType string, size int64) *CachedFileData {
return &CachedFileData{
diskPath: diskPath,
MimeType: mimeType,
Size: size,
isDisk: true,
}
}
// GetBase64Data 获取 base64 数据(自动处理内存/磁盘)
func (c *CachedFileData) GetBase64Data() (string, error) {
if !c.isDisk {
return c.base64Data, nil
}
c.diskMu.Lock()
defer c.diskMu.Unlock()
if c.diskClosed {
return "", fmt.Errorf("disk cache already closed")
}
// 从磁盘读取
data, err := os.ReadFile(c.diskPath)
if err != nil {
return "", fmt.Errorf("failed to read from disk cache: %w", err)
}
return string(data), nil
}
// SetBase64Data 设置 base64 数据(仅用于内存模式)
func (c *CachedFileData) SetBase64Data(data string) {
if !c.isDisk {
c.base64Data = data
}
}
// IsDisk 是否使用磁盘缓存
func (c *CachedFileData) IsDisk() bool {
return c.isDisk
}
// Close 关闭并清理资源
func (c *CachedFileData) Close() error {
if !c.isDisk {
c.base64Data = "" // 释放内存
return nil
}
c.diskMu.Lock()
defer c.diskMu.Unlock()
if c.diskClosed {
return nil
}
c.diskClosed = true
if c.diskPath != "" {
err := os.Remove(c.diskPath)
// 只有在删除成功且未扣减过统计时,才执行回调
if err == nil && !c.statDecremented && c.OnClose != nil {
c.OnClose(c.DiskSize)
c.statDecremented = true
}
return err
}
return nil
}
// NewURLFileSource 创建 URL 来源的 FileSource
func NewURLFileSource(url string) *FileSource {
return &FileSource{
Type: FileSourceTypeURL,
URL: url,
}
}
// NewBase64FileSource 创建 base64 来源的 FileSource
func NewBase64FileSource(base64Data string, mimeType string) *FileSource {
return &FileSource{
Type: FileSourceTypeBase64,
Base64Data: base64Data,
MimeType: mimeType,
}
}
// IsURL 判断是否是 URL 来源
func (f *FileSource) IsURL() bool {
return f.Type == FileSourceTypeURL
}
// IsBase64 判断是否是 base64 来源
func (f *FileSource) IsBase64() bool {
return f.Type == FileSourceTypeBase64
}
// GetIdentifier 获取文件标识符(用于日志和错误追踪)
func (f *FileSource) GetIdentifier() string {
if f.IsURL() {
if len(f.URL) > 100 {
return f.URL[:100] + "..."
}
return f.URL
}
if len(f.Base64Data) > 50 {
return "base64:" + f.Base64Data[:50] + "..."
}
return "base64:" + f.Base64Data
}
// GetRawData 获取原始数据URL 或完整的 base64 字符串)
func (f *FileSource) GetRawData() string {
if f.IsURL() {
return f.URL
}
return f.Base64Data
}
// SetCache 设置缓存数据
func (f *FileSource) SetCache(data *CachedFileData) {
f.cachedData = data
f.cacheLoaded = true
}
// IsRegistered 是否已注册到清理列表
func (f *FileSource) IsRegistered() bool {
return f.registered
}
// SetRegistered 设置注册状态
func (f *FileSource) SetRegistered(registered bool) {
f.registered = registered
}
// GetCache 获取缓存数据
func (f *FileSource) GetCache() *CachedFileData {
return f.cachedData
}
// HasCache 是否有缓存
func (f *FileSource) HasCache() bool {
return f.cacheLoaded && f.cachedData != nil
}
// ClearCache 清除缓存,释放内存和磁盘文件
func (f *FileSource) ClearCache() {
// 如果有缓存数据,先关闭它(会清理磁盘文件)
if f.cachedData != nil {
f.cachedData.Close()
}
f.cachedData = nil
f.cacheLoaded = false
}
// ClearRawData 清除原始数据,只保留必要的元信息
// 用于在处理完成后释放大文件的内存
func (f *FileSource) ClearRawData() {
// 保留 URL通常很短只清除大的 base64 数据
if f.IsBase64() && len(f.Base64Data) > 1024 {
f.Base64Data = ""
}
}

View File

@@ -32,10 +32,48 @@ type TokenCountMeta struct {
type FileMeta struct {
FileType
MimeType string
OriginData string // url or base64 data
Detail string
ParsedData *LocalFileData
MimeType string
Source *FileSource // 统一的文件来源URL 或 base64
Detail string // 图片细节级别low/high/auto
}
// NewFileMeta 创建新的 FileMeta
func NewFileMeta(fileType FileType, source *FileSource) *FileMeta {
return &FileMeta{
FileType: fileType,
Source: source,
}
}
// NewImageFileMeta 创建图片类型的 FileMeta
func NewImageFileMeta(source *FileSource, detail string) *FileMeta {
return &FileMeta{
FileType: FileTypeImage,
Source: source,
Detail: detail,
}
}
// GetIdentifier 获取文件标识符(用于日志)
func (f *FileMeta) GetIdentifier() string {
if f.Source != nil {
return f.Source.GetIdentifier()
}
return "unknown"
}
// IsURL 判断是否是 URL 来源
func (f *FileMeta) IsURL() bool {
return f.Source != nil && f.Source.IsURL()
}
// GetRawData 获取原始数据(兼容旧代码)
// Deprecated: 请使用 Source.GetRawData()
func (f *FileMeta) GetRawData() string {
if f.Source != nil {
return f.Source.GetRawData()
}
return ""
}
type RequestMeta struct {

View File

@@ -42,6 +42,8 @@ import {
TASK_ACTION_REMIX_GENERATE,
} from '../../../constants/common.constant';
import { CHANNEL_OPTIONS } from '../../../constants/channel.constants';
import { stringToColor } from '../../../helpers/render';
import { Avatar, Space } from '@douyinfe/semi-ui';
const colors = [
'amber',
@@ -288,6 +290,39 @@ export const getTaskLogsColumns = ({
);
},
},
{
key: COLUMN_KEYS.USERNAME,
title: t('用户'),
dataIndex: 'username',
render: (text, record, index) => {
if (!isAdminUser) {
return <></>;
}
const displayName = record.display_name;
const label = displayName || text || t('未知');
const avatarText =
typeof displayName === 'string' && displayName.length > 0
? displayName[0]
: typeof text === 'string' && text.length > 0
? text[0]
: '?';
return (
<Space>
<Avatar
size='extra-small'
color={stringToColor(label)}
style={{ cursor: 'default' }}
>
{avatarText}
</Avatar>
<Typography.Text ellipsis={{ showTooltip: true }}>
{label}
</Typography.Text>
</Space>
);
},
},
{
key: COLUMN_KEYS.PLATFORM,
title: t('平台'),

View File

@@ -93,6 +93,15 @@ const LogsFilters = ({
size='small'
/>
<Form.Input
field='request_id'
prefix={<IconSearch />}
placeholder={t('Request ID')}
showClear
pure
size='small'
/>
{isAdminUser && (
<>
<Form.Input

View File

@@ -128,7 +128,11 @@ const SubscriptionPlansCard = ({
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
@@ -152,7 +156,11 @@ const SubscriptionPlansCard = ({
showSuccess(t('已打开支付页面'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
@@ -177,7 +185,11 @@ const SubscriptionPlansCard = ({
showSuccess(t('已发起支付'));
closeBuy();
} else {
showError(res.data?.data || res.data?.message || t('支付失败'));
const errorMsg =
typeof res.data?.data === 'string'
? res.data.data
: res.data?.message || t('支付失败');
showError(errorMsg);
}
} catch (e) {
showError(t('支付请求失败'));
@@ -269,9 +281,13 @@ const SubscriptionPlansCard = ({
</div>
</Card>
{/* 套餐列表骨架屏 */}
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full'>
{[1, 2, 3].map((i) => (
<Card key={i} className='!rounded-xl' bodyStyle={{ padding: 16 }}>
<Card
key={i}
className='!rounded-xl w-full h-full'
bodyStyle={{ padding: 16 }}
>
<Skeleton.Title
active
style={{ width: '60%', height: 24, marginBottom: 8 }}
@@ -435,7 +451,7 @@ const SubscriptionPlansCard = ({
{/* 可购买套餐 - 标准定价卡片 */}
{plans.length > 0 ? (
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 gap-5 w-full'>
{plans.map((p, index) => {
const plan = p?.plan;
const totalAmount = Number(plan?.total_amount || 0);
@@ -477,15 +493,15 @@ const SubscriptionPlansCard = ({
return (
<Card
key={plan?.id}
className={`!rounded-xl transition-all hover:shadow-lg ${
className={`!rounded-xl transition-all hover:shadow-lg w-full h-full ${
isPopular ? 'ring-2 ring-purple-500' : ''
}`}
bodyStyle={{ padding: 0 }}
>
<div className='p-4'>
<div className='p-4 h-full flex flex-col'>
{/* 推荐标签 */}
{isPopular && (
<div className='text-center mb-2'>
<div className='mb-2'>
<Tag color='purple' shape='circle' size='small'>
<Sparkles size={10} className='mr-1' />
{t('推荐')}
@@ -493,7 +509,7 @@ const SubscriptionPlansCard = ({
</div>
)}
{/* 套餐名称 */}
<div className='text-center mb-3'>
<div className='mb-3'>
<Typography.Title
heading={5}
ellipsis={{ rows: 1, showTooltip: true }}
@@ -514,8 +530,8 @@ const SubscriptionPlansCard = ({
</div>
{/* 价格区域 */}
<div className='text-center py-2'>
<div className='flex items-baseline justify-center'>
<div className='py-2'>
<div className='flex items-baseline justify-start'>
<span className='text-xl font-bold text-purple-600'>
{symbol}
</span>
@@ -526,7 +542,7 @@ const SubscriptionPlansCard = ({
</div>
{/* 套餐权益描述 */}
<div className='flex flex-col items-center gap-1 pb-2'>
<div className='flex flex-col items-start gap-1 pb-2'>
{planBenefits.map((item) => {
const content = (
<div className='flex items-center gap-2 text-xs text-gray-500'>
@@ -538,7 +554,7 @@ const SubscriptionPlansCard = ({
return (
<div
key={item.label}
className='w-full flex justify-center'
className='w-full flex justify-start'
>
{content}
</div>
@@ -546,7 +562,7 @@ const SubscriptionPlansCard = ({
}
return (
<Tooltip key={item.label} content={item.tooltip}>
<div className='w-full flex justify-center'>
<div className='w-full flex justify-start'>
{content}
</div>
</Tooltip>
@@ -554,36 +570,38 @@ const SubscriptionPlansCard = ({
})}
</div>
<Divider margin={12} />
<div className='mt-auto'>
<Divider margin={12} />
{/* 购买按钮 */}
{(() => {
const count = getPlanPurchaseCount(p?.plan?.id);
const reached = limit > 0 && count >= limit;
const tip = reached
? t('已达到购买上限') + ` (${count}/${limit})`
: '';
const buttonEl = (
<Button
theme='outline'
type='tertiary'
block
disabled={reached}
onClick={() => {
if (!reached) openBuy(p);
}}
>
{reached ? t('已达上限') : t('立即订阅')}
</Button>
);
return reached ? (
<Tooltip content={tip} position='top'>
{buttonEl}
</Tooltip>
) : (
buttonEl
);
})()}
{/* 购买按钮 */}
{(() => {
const count = getPlanPurchaseCount(p?.plan?.id);
const reached = limit > 0 && count >= limit;
const tip = reached
? t('已达到购买上限') + ` (${count}/${limit})`
: '';
const buttonEl = (
<Button
theme='outline'
type='tertiary'
block
disabled={reached}
onClick={() => {
if (!reached) openBuy(p);
}}
>
{reached ? t('已达上限') : t('立即订阅')}
</Button>
);
return reached ? (
<Tooltip content={tip} position='top'>
{buttonEl}
</Tooltip>
) : (
buttonEl
);
})()}
</div>
</div>
</Card>
);

View File

@@ -249,7 +249,9 @@ const TopUp = () => {
document.body.removeChild(form);
}
} else {
showError(data);
const errorMsg =
typeof data === 'string' ? data : message || t('支付失败');
showError(errorMsg);
}
} else {
showError(res);
@@ -293,7 +295,9 @@ const TopUp = () => {
if (message === 'success') {
processCreemCallback(data);
} else {
showError(data);
const errorMsg =
typeof data === 'string' ? data : message || t('支付失败');
showError(errorMsg);
}
} else {
showError(res);

View File

@@ -605,34 +605,6 @@ export function stringToColor(str) {
return colors[i];
}
// High-contrast color palette for group tags (avoids similar blue/teal shades)
const groupColors = [
'red',
'orange',
'yellow',
'lime',
'green',
'cyan',
'blue',
'indigo',
'violet',
'purple',
'pink',
'amber',
'grey',
];
export function groupToColor(str) {
// Use a better hash algorithm for more even distribution
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash = hash & hash;
}
hash = Math.abs(hash);
return groupColors[hash % groupColors.length];
}
// 渲染带有模型图标的标签
export function renderModelTag(modelName, options = {}) {
const {
@@ -701,7 +673,7 @@ export function renderGroup(group) {
<span key={group}>
{groups.map((group) => (
<Tag
color={tagColors[group] || groupToColor(group)}
color={tagColors[group] || stringToColor(group)}
key={group}
shape='circle'
onClick={async (event) => {

View File

@@ -40,6 +40,7 @@ export const useTaskLogsData = () => {
FINISH_TIME: 'finish_time',
DURATION: 'duration',
CHANNEL: 'channel',
USERNAME: 'username',
PLATFORM: 'platform',
TYPE: 'type',
TASK_ID: 'task_id',
@@ -104,6 +105,7 @@ export const useTaskLogsData = () => {
// For non-admin users, force-hide admin-only columns (does not touch admin settings)
if (!isAdminUser) {
merged[COLUMN_KEYS.CHANNEL] = false;
merged[COLUMN_KEYS.USERNAME] = false;
}
setVisibleColumns(merged);
} catch (e) {
@@ -122,6 +124,7 @@ export const useTaskLogsData = () => {
[COLUMN_KEYS.FINISH_TIME]: true,
[COLUMN_KEYS.DURATION]: true,
[COLUMN_KEYS.CHANNEL]: isAdminUser,
[COLUMN_KEYS.USERNAME]: isAdminUser,
[COLUMN_KEYS.PLATFORM]: true,
[COLUMN_KEYS.TYPE]: true,
[COLUMN_KEYS.TASK_ID]: true,
@@ -151,7 +154,10 @@ export const useTaskLogsData = () => {
const updatedColumns = {};
allKeys.forEach((key) => {
if (key === COLUMN_KEYS.CHANNEL && !isAdminUser) {
if (
(key === COLUMN_KEYS.CHANNEL || key === COLUMN_KEYS.USERNAME) &&
!isAdminUser
) {
updatedColumns[key] = false;
} else {
updatedColumns[key] = checked;

View File

@@ -94,6 +94,7 @@ export const useLogsData = () => {
model_name: '',
channel: '',
group: '',
request_id: '',
dateRange: [
timestamp2string(getTodayStartTimestamp()),
timestamp2string(now.getTime() / 1000 + 3600),
@@ -230,6 +231,7 @@ export const useLogsData = () => {
end_timestamp,
channel: formValues.channel || '',
group: formValues.group || '',
request_id: formValues.request_id || '',
logType: formValues.logType ? parseInt(formValues.logType) : 0,
};
};
@@ -348,6 +350,12 @@ export const useLogsData = () => {
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
});
}
if (logs[i].request_id) {
expandDataLocal.push({
key: t('Request ID'),
value: logs[i].request_id,
});
}
if (other?.ws || other?.audio) {
expandDataLocal.push({
key: t('语音输入'),
@@ -620,6 +628,7 @@ export const useLogsData = () => {
end_timestamp,
channel,
group,
request_id,
logType: formLogType,
} = getFormValues();
@@ -633,9 +642,9 @@ export const useLogsData = () => {
let localStartTimestamp = Date.parse(start_timestamp) / 1000;
let localEndTimestamp = Date.parse(end_timestamp) / 1000;
if (isAdminUser) {
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}`;
url = `/api/log/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&username=${username}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&channel=${channel}&group=${group}&request_id=${request_id}`;
} else {
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}`;
url = `/api/log/self/?p=${startIdx}&page_size=${pageSize}&type=${currentLogType}&token_name=${token_name}&model_name=${model_name}&start_timestamp=${localStartTimestamp}&end_timestamp=${localEndTimestamp}&group=${group}&request_id=${request_id}`;
}
url = encodeURI(url);
const res = await API.get(url);

View File

@@ -2316,6 +2316,45 @@
"输入验证码完成设置": "Enter verification code to complete setup",
"输出": "Output",
"输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}": "Output {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}}",
"磁盘缓存设置(磁盘换内存)": "Disk Cache Settings (Disk Swap Memory)",
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. Suitable for requests with large images/files. SSD recommended.",
"启用磁盘缓存": "Enable Disk Cache",
"将大请求体临时存储到磁盘": "Store large request bodies temporarily on disk",
"磁盘缓存阈值 (MB)": "Disk Cache Threshold (MB)",
"请求体超过此大小时使用磁盘缓存": "Use disk cache when request body exceeds this size",
"磁盘缓存最大总量 (MB)": "Max Disk Cache Size (MB)",
"可用空间: {{free}} / 总空间: {{total}}": "Free: {{free}} / Total: {{total}}",
"磁盘缓存占用的最大空间": "Maximum space occupied by disk cache",
"留空使用系统临时目录": "Leave empty to use system temp directory",
"例如 /var/cache/new-api": "e.g. /var/cache/new-api",
"性能监控": "Performance Monitor",
"刷新统计": "Refresh Stats",
"重置统计": "Reset Stats",
"执行 GC": "Run GC",
"请求体磁盘缓存": "Request Body Disk Cache",
"活跃文件": "Active Files",
"磁盘命中": "Disk Hits",
"请求体内存缓存": "Request Body Memory Cache",
"当前缓存大小": "Current Cache Size",
"活跃缓存数": "Active Cache Count",
"内存命中": "Memory Hits",
"缓存目录磁盘空间": "Cache Directory Disk Space",
"磁盘可用空间小于缓存最大总量设置": "Disk free space is less than max cache size setting",
"已分配内存": "Allocated Memory",
"总分配内存": "Total Allocated Memory",
"系统内存": "System Memory",
"GC 次数": "GC Count",
"Goroutine 数": "Goroutine Count",
"目录文件数": "Directory File Count",
"目录总大小": "Directory Total Size",
"磁盘缓存已清理": "Disk cache cleared",
"清理失败": "Cleanup failed",
"统计已重置": "Statistics reset",
"重置失败": "Reset failed",
"GC 已执行": "GC executed",
"GC 执行失败": "GC execution failed",
"缓存目录": "Cache Directory",
"可用": "Available",
"输出价格": "Output Price",
"输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "Output price: {{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (Completion ratio: {{completionRatio}})",
"输出倍率 {{completionRatio}}": "Output ratio {{completionRatio}}",
@@ -2687,6 +2726,46 @@
"套餐名称": "Plan Name",
"应付金额": "Amount Due",
"支付": "Pay",
"管理员未开启在线支付功能,请联系管理员配置。": "Online payment is not enabled by the admin. Please contact the administrator."
"管理员未开启在线支付功能,请联系管理员配置。": "Online payment is not enabled by the admin. Please contact the administrator.",
"磁盘缓存设置(磁盘换内存)": "Disk Cache Settings (Disk Swap Memory)",
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "When enabled, large request bodies are temporarily stored on disk instead of memory, significantly reducing memory usage. Suitable for requests with large images/files. SSD recommended.",
"启用磁盘缓存": "Enable Disk Cache",
"将大请求体临时存储到磁盘": "Store large request bodies temporarily on disk",
"磁盘缓存阈值 (MB)": "Disk Cache Threshold (MB)",
"请求体超过此大小时使用磁盘缓存": "Use disk cache when request body exceeds this size",
"磁盘缓存最大总量 (MB)": "Max Disk Cache Size (MB)",
"可用空间: {{free}} / 总空间: {{total}}": "Free: {{free}} / Total: {{total}}",
"磁盘缓存占用的最大空间": "Maximum space occupied by disk cache",
"留空使用系统临时目录": "Leave empty to use system temp directory",
"例如 /var/cache/new-api": "e.g. /var/cache/new-api",
"性能监控": "Performance Monitor",
"刷新统计": "Refresh Stats",
"重置统计": "Reset Stats",
"执行 GC": "Run GC",
"请求体磁盘缓存": "Request Body Disk Cache",
"活跃文件": "Active Files",
"磁盘命中": "Disk Hits",
"请求体内存缓存": "Request Body Memory Cache",
"当前缓存大小": "Current Cache Size",
"活跃缓存数": "Active Cache Count",
"内存命中": "Memory Hits",
"缓存目录磁盘空间": "Cache Directory Disk Space",
"磁盘可用空间小于缓存最大总量设置": "Disk free space is less than max cache size setting",
"已分配内存": "Allocated Memory",
"总分配内存": "Total Allocated Memory",
"系统内存": "System Memory",
"GC 次数": "GC Count",
"Goroutine 数": "Goroutine Count",
"目录文件数": "Directory File Count",
"目录总大小": "Directory Total Size",
"磁盘缓存已清理": "Disk cache cleared",
"清理失败": "Cleanup failed",
"统计已重置": "Statistics reset",
"重置失败": "Reset failed",
"GC 已执行": "GC executed",
"GC execution failed": "GC execution failed",
"Cache Directory": "Cache Directory",
"Available": "Available",
"输出价格": "Output Price"
}
}

View File

@@ -442,6 +442,9 @@
"兑换人ID": "兑换人ID",
"兑换成功!": "兑换成功!",
"兑换码充值": "兑换码充值",
"确认清理不活跃的磁盘缓存?": "确认清理不活跃的磁盘缓存?",
"这将删除超过 10 分钟未使用的临时缓存文件": "这将删除超过 10 分钟未使用的临时缓存文件",
"清理不活跃缓存": "清理不活跃缓存",
"兑换码创建成功": "兑换码创建成功",
"兑换码创建成功,是否下载兑换码?": "兑换码创建成功,是否下载兑换码?",
"兑换码创建成功!": "兑换码创建成功!",
@@ -1820,6 +1823,17 @@
"系统文档和帮助信息": "系统文档和帮助信息",
"系统消息": "系统消息",
"系统管理功能": "系统管理功能",
"系统性能监控": "系统性能监控",
"启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。": "启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。",
"启用性能监控": "启用性能监控",
"超过阈值时拒绝新请求": "超过阈值时拒绝新请求",
"CPU 阈值 (%)": "CPU 阈值 (%)",
"CPU 使用率超过此值时拒绝请求": "CPU 使用率超过此值时拒绝请求",
"内存 阈值 (%)": "内存 阈值 (%)",
"内存使用率超过此值时拒绝请求": "内存使用率超过此值时拒绝请求",
"磁盘 阈值 (%)": "磁盘 阈值 (%)",
"磁盘使用率超过此值时拒绝请求": "磁盘使用率超过此值时拒绝请求",
"保存性能设置": "保存性能设置",
"系统设置": "系统设置",
"系统访问令牌": "系统访问令牌",
"约": "约",
@@ -2302,6 +2316,45 @@
"输入验证码完成设置": "输入验证码完成设置",
"输出": "输出",
"输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}": "输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}",
"磁盘缓存设置(磁盘换内存)": "磁盘缓存设置(磁盘换内存)",
"启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。": "启用磁盘缓存后,大请求体将临时存储到磁盘而非内存,可显著降低内存占用,适用于处理包含大量图片/文件的请求。建议在 SSD 环境下使用。",
"启用磁盘缓存": "启用磁盘缓存",
"将大请求体临时存储到磁盘": "将大请求体临时存储到磁盘",
"磁盘缓存阈值 (MB)": "磁盘缓存阈值 (MB)",
"请求体超过此大小时使用磁盘缓存": "请求体超过此大小时使用磁盘缓存",
"磁盘缓存最大总量 (MB)": "磁盘缓存最大总量 (MB)",
"可用空间: {{free}} / 总空间: {{total}}": "可用空间: {{free}} / 总空间: {{total}}",
"磁盘缓存占用的最大空间": "磁盘缓存占用的最大空间",
"留空使用系统临时目录": "留空使用系统临时目录",
"例如 /var/cache/new-api": "例如 /var/cache/new-api",
"性能监控": "性能监控",
"刷新统计": "刷新统计",
"重置统计": "重置统计",
"执行 GC": "执行 GC",
"请求体磁盘缓存": "请求体磁盘缓存",
"活跃文件": "活跃文件",
"磁盘命中": "磁盘命中",
"请求体内存缓存": "请求体内存缓存",
"当前缓存大小": "当前缓存大小",
"活跃缓存数": "活跃缓存数",
"内存命中": "内存命中",
"缓存目录磁盘空间": "缓存目录磁盘空间",
"磁盘可用空间小于缓存最大总量设置": "磁盘可用空间小于缓存最大总量设置",
"已分配内存": "已分配内存",
"总分配内存": "总分配内存",
"系统内存": "系统内存",
"GC 次数": "GC 次数",
"Goroutine 数": "Goroutine 数",
"目录文件数": "目录文件数",
"目录总大小": "目录总大小",
"磁盘缓存已清理": "磁盘缓存已清理",
"清理失败": "清理失败",
"统计已重置": "统计已重置",
"重置失败": "重置失败",
"GC 已执行": "GC 已执行",
"GC 执行失败": "GC 执行失败",
"缓存目录": "缓存目录",
"可用": "可用",
"输出价格": "输出价格",
"输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})": "输出价格:{{symbol}}{{price}} * {{completionRatio}} = {{symbol}}{{total}} / 1M tokens (补全倍率: {{completionRatio}})",
"输出倍率 {{completionRatio}}": "输出倍率 {{completionRatio}}",

View File

@@ -65,6 +65,10 @@ export default function SettingsPerformance(props) {
'performance_setting.disk_cache_threshold_mb': 10,
'performance_setting.disk_cache_max_size_mb': 1024,
'performance_setting.disk_cache_path': '',
'performance_setting.monitor_enabled': false,
'performance_setting.monitor_cpu_threshold': 90,
'performance_setting.monitor_memory_threshold': 90,
'performance_setting.monitor_disk_threshold': 90,
});
const refForm = useRef();
const [inputsRow, setInputsRow] = useState(inputs);
@@ -274,6 +278,70 @@ export default function SettingsPerformance(props) {
</Col>
)}
</Row>
</Form.Section>
<Form.Section text={t('系统性能监控')}>
<Banner
type='info'
description={t(
'启用性能监控后,当系统资源使用率超过设定阈值时,将拒绝新的 Relay 请求 (/v1, /v1beta 等),以保护系统稳定性。',
)}
style={{ marginBottom: 16 }}
/>
<Row gutter={16}>
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
<Form.Switch
field={'performance_setting.monitor_enabled'}
label={t('启用性能监控')}
extraText={t('超过阈值时拒绝新请求')}
size='default'
checkedText=''
uncheckedText=''
onChange={handleFieldChange(
'performance_setting.monitor_enabled',
)}
/>
</Col>
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
<Form.InputNumber
field={'performance_setting.monitor_cpu_threshold'}
label={t('CPU 阈值 (%)')}
extraText={t('CPU 使用率超过此值时拒绝请求')}
min={0}
max={100}
onChange={handleFieldChange(
'performance_setting.monitor_cpu_threshold',
)}
disabled={!inputs['performance_setting.monitor_enabled']}
/>
</Col>
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
<Form.InputNumber
field={'performance_setting.monitor_memory_threshold'}
label={t('内存 阈值 (%)')}
extraText={t('内存使用率超过此值时拒绝请求')}
min={0}
max={100}
onChange={handleFieldChange(
'performance_setting.monitor_memory_threshold',
)}
disabled={!inputs['performance_setting.monitor_enabled']}
/>
</Col>
<Col xs={24} sm={12} md={6} lg={6} xl={6}>
<Form.InputNumber
field={'performance_setting.monitor_disk_threshold'}
label={t('磁盘 阈值 (%)')}
extraText={t('磁盘使用率超过此值时拒绝请求')}
min={0}
max={100}
onChange={handleFieldChange(
'performance_setting.monitor_disk_threshold',
)}
disabled={!inputs['performance_setting.monitor_enabled']}
/>
</Col>
</Row>
<Row>
<Button size='default' onClick={onSubmit}>
{t('保存性能设置')}
@@ -291,11 +359,11 @@ export default function SettingsPerformance(props) {
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button onClick={fetchStats}>{t('刷新统计')}</Button>
<Popconfirm
title={t('确认清理磁盘缓存?')}
content={t('这将删除所有临时缓存文件')}
title={t('确认清理不活跃的磁盘缓存?')}
content={t('这将删除超过 10 分钟未使用的临时缓存文件')}
onConfirm={clearDiskCache}
>
<Button type='warning'>{t('清理磁盘缓存')}</Button>
<Button type='warning'>{t('清理不活跃缓存')}</Button>
</Popconfirm>
<Button onClick={resetStats}>{t('重置统计')}</Button>
<Button onClick={forceGC}>{t('执行 GC')}</Button>
@@ -490,7 +558,10 @@ export default function SettingsPerformance(props) {
key: t('Goroutine 数'),
value: stats.memory_stats.num_goroutine,
},
{ key: t('缓存目录'), value: stats.disk_cache_info.path },
{
key: t('缓存目录'),
value: stats.disk_cache_info.path,
},
{
key: t('目录文件数'),
value: stats.disk_cache_info.file_count,