mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-03 02:36:32 +00:00
Compare commits
25 Commits
feature/su
...
fix/subscr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4617097fb | ||
|
|
ca91d6992e | ||
|
|
65b2ca4176 | ||
|
|
7a4fc68bcc | ||
|
|
7cfed0df8e | ||
|
|
564f407a6b | ||
|
|
117c9a8699 | ||
|
|
e2ebd42a8c | ||
|
|
9ef7740fe7 | ||
|
|
89b2782675 | ||
|
|
e7d5c61d53 | ||
|
|
bb54ed91dc | ||
|
|
d8d1f141c2 | ||
|
|
dd467ed592 | ||
|
|
f1e6c1bf77 | ||
|
|
1ee80930d4 | ||
|
|
35a4c586aa | ||
|
|
85b5d0100a | ||
|
|
6a9522ac5b | ||
|
|
3b76b770b9 | ||
|
|
59c076978e | ||
|
|
5889856a55 | ||
|
|
9da3412fde | ||
|
|
8d67c571e4 | ||
|
|
34ac066f36 |
32
.github/workflows/docker-image-arm64.yml
vendored
32
.github/workflows/docker-image-arm64.yml
vendored
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
176
common/disk_cache.go
Normal 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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 是否生成初始令牌,默认关闭。
|
||||
|
||||
33
common/performance_config.go
Normal file
33
common/performance_config.go
Normal 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)
|
||||
}
|
||||
@@ -106,6 +106,16 @@ func GetJsonString(data any) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NormalizeBillingPreference clamps the billing preference to valid values.
|
||||
func NormalizeBillingPreference(pref string) string {
|
||||
switch strings.TrimSpace(pref) {
|
||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||
return strings.TrimSpace(pref)
|
||||
default:
|
||||
return "subscription_first"
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail masks a user email to prevent PII leakage in logs
|
||||
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
||||
func MaskEmail(email string) string {
|
||||
|
||||
81
common/system_monitor.go
Normal file
81
common/system_monitor.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
@@ -159,7 +160,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
if priceData.FreeModel {
|
||||
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
||||
} else {
|
||||
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
if newAPIError != nil {
|
||||
return
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
383
controller/subscription.go
Normal file
383
controller/subscription.go
Normal file
@@ -0,0 +1,383 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- Shared types ----
|
||||
|
||||
type SubscriptionPlanDTO struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
}
|
||||
|
||||
type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
func GetSubscriptionSelf(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
settingMap, _ := model.GetUserSetting(userId, false)
|
||||
pref := common.NormalizeBillingPreference(settingMap.BillingPreference)
|
||||
|
||||
// Get all subscriptions (including expired)
|
||||
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
allSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
// Get active subscriptions for backward compatibility
|
||||
activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
activeSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"billing_preference": pref,
|
||||
"subscriptions": activeSubscriptions, // all active subscriptions
|
||||
"all_subscriptions": allSubscriptions, // all subscriptions including expired
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req BillingPreferenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
pref := common.NormalizeBillingPreference(req.BillingPreference)
|
||||
|
||||
user, err := model.GetUserById(userId, true)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
current := user.GetSetting()
|
||||
current.BillingPreference = pref
|
||||
user.SetSetting(current)
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
type AdminUpsertSubscriptionPlanRequest struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
}
|
||||
|
||||
func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = 0
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
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"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
return
|
||||
}
|
||||
err := model.DB.Create(&req.Plan).Error
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(req.Plan.Id)
|
||||
common.ApiSuccess(c, req.Plan)
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
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"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
return
|
||||
}
|
||||
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// update plan (allow zero values updates with map)
|
||||
updateMap := map[string]interface{}{
|
||||
"title": req.Plan.Title,
|
||||
"subtitle": req.Plan.Subtitle,
|
||||
"price_amount": req.Plan.PriceAmount,
|
||||
"currency": req.Plan.Currency,
|
||||
"duration_unit": req.Plan.DurationUnit,
|
||||
"duration_value": req.Plan.DurationValue,
|
||||
"custom_seconds": req.Plan.CustomSeconds,
|
||||
"enabled": req.Plan.Enabled,
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"quota_reset_period": req.Plan.QuotaResetPeriod,
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(id)
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
type AdminUpdateSubscriptionPlanStatusRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpdateSubscriptionPlanStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(id)
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
type AdminBindSubscriptionRequest struct {
|
||||
UserId int `json:"user_id"`
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func AdminBindSubscription(c *gin.Context) {
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Admin: user subscription management ----
|
||||
|
||||
func AdminListUserSubscriptions(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
subs, err := model.GetAllUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, subs)
|
||||
}
|
||||
|
||||
type AdminCreateUserSubscriptionRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
|
||||
func AdminCreateUserSubscription(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
var req AdminCreateUserSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// AdminInvalidateUserSubscription cancels a user subscription immediately.
|
||||
func AdminInvalidateUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminInvalidateUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// AdminDeleteUserSubscription hard-deletes a user subscription.
|
||||
func AdminDeleteUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminDeleteUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
129
controller/subscription_payment_creem.go
Normal file
129
controller/subscription_payment_creem.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionCreemPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
var req SubscriptionCreemPayRequest
|
||||
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read subscription creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.CreemProductId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
||||
return
|
||||
}
|
||||
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
|
||||
common.ApiErrorMsg(c, "Creem Webhook 未配置")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reference := "sub-creem-ref-" + randstr.String(6)
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
|
||||
|
||||
// create pending order first
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse Creem checkout generator by building a lightweight product reference.
|
||||
currency := "USD"
|
||||
switch operation_setting.GetGeneralSetting().QuotaDisplayType {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
currency = "CNY"
|
||||
case operation_setting.QuotaDisplayTypeUSD:
|
||||
currency = "USD"
|
||||
default:
|
||||
currency = "USD"
|
||||
}
|
||||
product := &CreemProduct{
|
||||
ProductId: plan.CreemProductId,
|
||||
Name: plan.Title,
|
||||
Price: plan.PriceAmount,
|
||||
Currency: currency,
|
||||
Quota: 0,
|
||||
}
|
||||
|
||||
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
"order_id": referenceId,
|
||||
},
|
||||
})
|
||||
}
|
||||
216
controller/subscription_payment_epay.go
Normal file
216
controller/subscription_payment_epay.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/go-epay/epay"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SubscriptionEpayPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestEpay(c *gin.Context) {
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.PriceAmount < 0.01 {
|
||||
common.ApiErrorMsg(c, "套餐金额过低")
|
||||
return
|
||||
}
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
common.ApiErrorMsg(c, "支付方式不存在")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
return
|
||||
}
|
||||
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
common.ApiErrorMsg(c, "当前管理员未配置支付信息")
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
common.ApiErrorMsg(c, "创建订单失败")
|
||||
return
|
||||
}
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
Type: req.PaymentMethod,
|
||||
ServiceTradeNo: tradeNo,
|
||||
Name: fmt.Sprintf("SUB:%s", plan.Title),
|
||||
Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
|
||||
Device: epay.PC,
|
||||
NotifyUrl: notifyUrl,
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
_ = model.ExpireSubscriptionOrder(tradeNo)
|
||||
common.ApiErrorMsg(c, "拉起支付失败")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||
}
|
||||
|
||||
func SubscriptionEpayNotify(c *gin.Context) {
|
||||
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"))
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = c.Writer.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// SubscriptionEpayReturn handles browser return after payment.
|
||||
// It verifies the payload and completes the order, then redirects to console.
|
||||
func SubscriptionEpayReturn(c *gin.Context) {
|
||||
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")
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=success")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=pending")
|
||||
}
|
||||
138
controller/subscription_payment_stripe.go
Normal file
138
controller/subscription_payment_stripe.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionStripePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.StripePriceId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
||||
return
|
||||
}
|
||||
if setting.StripeWebhookSecret == "" {
|
||||
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
|
||||
|
||||
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"pay_link": payLink,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func genStripeSubscriptionLink(referenceId string, customerId string, email string, priceId string) (string, error) {
|
||||
stripe.Key = setting.StripeApiSecret
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceId),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
}
|
||||
|
||||
if "" == customerId {
|
||||
if "" != email {
|
||||
params.CustomerEmail = stripe.String(email)
|
||||
}
|
||||
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
|
||||
} else {
|
||||
params.Customer = stripe.String(customerId)
|
||||
}
|
||||
|
||||
result, err := session.New(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.URL, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -65,12 +65,10 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
type EpayRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
TopUpCode string `json:"top_up_code"`
|
||||
}
|
||||
|
||||
type AmountRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
TopUpCode string `json:"top_up_code"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func GetEpayClient() *epay.Client {
|
||||
@@ -230,10 +228,32 @@ func UnlockOrder(tradeNo string) {
|
||||
}
|
||||
|
||||
func EpayNotify(c *gin.Context) {
|
||||
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{})
|
||||
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("易支付回调失败 未找到配置信息")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -227,16 +228,6 @@ type CreemWebhookEvent struct {
|
||||
} `json:"object"`
|
||||
}
|
||||
|
||||
// 保留旧的结构体作为兼容
|
||||
type CreemWebhookData struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
RequestId string `json:"request_id"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func CreemWebhook(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
@@ -308,7 +299,19 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款(充值)
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -185,6 +186,22 @@ func sessionCompleted(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
payload := map[string]any{
|
||||
"customer": customerId,
|
||||
"amount_total": event.GetObjectValue("amount_total"),
|
||||
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
||||
"event_type": string(event.Type),
|
||||
}
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId)
|
||||
if err != nil {
|
||||
log.Println(err.Error(), referenceId)
|
||||
@@ -209,6 +226,16 @@ func sessionExpired(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription order expiration
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Println("充值订单不存在", referenceId)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -13,6 +13,7 @@ type UserSetting struct {
|
||||
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
7
main.go
7
main.go
@@ -106,6 +106,9 @@ func main() {
|
||||
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
|
||||
service.StartCodexCredentialAutoRefreshTask()
|
||||
|
||||
// Subscription quota reset task (daily/weekly/monthly/custom)
|
||||
service.StartSubscriptionQuotaResetTask()
|
||||
|
||||
if common.IsMasterNode && constant.UpdateTask {
|
||||
gopool.Go(func() {
|
||||
controller.UpdateMidjourneyTaskBulk()
|
||||
@@ -271,5 +274,9 @@ func InitResources() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 启动系统监控
|
||||
common.StartSystemMonitor()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
65
middleware/performance.go
Normal 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
|
||||
}
|
||||
22
model/db_time.go
Normal file
22
model/db_time.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
|
||||
// GetDBTimestamp returns a UNIX timestamp from database time.
|
||||
// Falls back to application time on error.
|
||||
func GetDBTimestamp() int64 {
|
||||
var ts int64
|
||||
var err error
|
||||
switch {
|
||||
case common.UsingPostgreSQL:
|
||||
err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error
|
||||
case common.UsingSQLite:
|
||||
err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error
|
||||
default:
|
||||
err = DB.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error
|
||||
}
|
||||
if err != nil || ts <= 0 {
|
||||
return common.GetTimestamp()
|
||||
}
|
||||
return ts
|
||||
}
|
||||
20
model/log.go
20
model/log.go
@@ -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)
|
||||
}
|
||||
|
||||
160
model/main.go
160
model/main.go
@@ -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,10 +271,22 @@ func migrateDB() error {
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
&Checkin{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&SubscriptionPreConsumeRecord{},
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -302,6 +317,9 @@ func migrateDBFast() error {
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
{&Checkin{}, "Checkin"},
|
||||
{&SubscriptionOrder{}, "SubscriptionOrder"},
|
||||
{&UserSubscription{}, "UserSubscription"},
|
||||
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
@@ -326,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
|
||||
}
|
||||
@@ -338,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
1176
model/subscription.go
Normal file
1176
model/subscription.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -204,6 +204,10 @@ func updateUserGroupCache(userId int, group string) error {
|
||||
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
|
||||
}
|
||||
|
||||
func UpdateUserGroupCache(userId int, group string) error {
|
||||
return updateUserGroupCache(userId, group)
|
||||
}
|
||||
|
||||
func updateUserNameCache(userId int, username string) error {
|
||||
if !common.RedisEnabled {
|
||||
return nil
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -113,9 +113,26 @@ type RelayInfo struct {
|
||||
UserQuota int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||
IsChannelTest bool // channel test request
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
// BillingSource indicates whether this request is billed from wallet quota or subscription.
|
||||
// "" or "wallet" => wallet; "subscription" => subscription
|
||||
BillingSource string
|
||||
// SubscriptionId is the user_subscriptions.id used when BillingSource == "subscription"
|
||||
SubscriptionId int
|
||||
// SubscriptionPreConsumed is the amount pre-consumed on subscription item (quota units or 1)
|
||||
SubscriptionPreConsumed int64
|
||||
// SubscriptionPostDelta is the post-consume delta applied to amount_used (quota units; can be negative).
|
||||
SubscriptionPostDelta int64
|
||||
// SubscriptionPlanId / SubscriptionPlanTitle are used for logging/UI display.
|
||||
SubscriptionPlanId int
|
||||
SubscriptionPlanTitle string
|
||||
// RequestId is used for idempotent pre-consume/refund
|
||||
RequestId string
|
||||
// SubscriptionAmountTotal / SubscriptionAmountUsedAfterPreConsume are used to compute remaining in logs.
|
||||
SubscriptionAmountTotal int64
|
||||
SubscriptionAmountUsedAfterPreConsume int64
|
||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||
IsChannelTest bool // channel test request
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
@@ -400,9 +417,14 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
|
||||
// firstResponseTime = time.Now() - 1 second
|
||||
|
||||
reqId := common.GetContextKeyString(c, common.RequestIdKey)
|
||||
if reqId == "" {
|
||||
reqId = common.GetTimeString() + common.GetRandomString(8)
|
||||
}
|
||||
info := &RelayInfo{
|
||||
Request: request,
|
||||
|
||||
RequestId: reqId,
|
||||
UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId),
|
||||
UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup),
|
||||
UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup),
|
||||
|
||||
@@ -58,6 +58,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
|
||||
//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)
|
||||
|
||||
@@ -119,6 +120,39 @@ func SetApiRouter(router *gin.Engine) {
|
||||
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription billing (plans, purchase, admin management)
|
||||
subscriptionRoute := apiRouter.Group("/subscription")
|
||||
subscriptionRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
|
||||
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
|
||||
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
|
||||
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
|
||||
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
|
||||
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
|
||||
}
|
||||
subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
|
||||
subscriptionAdminRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans)
|
||||
subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan)
|
||||
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
|
||||
subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus)
|
||||
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
|
||||
|
||||
// User subscription management (admin)
|
||||
subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions)
|
||||
subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription)
|
||||
subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription)
|
||||
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
|
||||
}
|
||||
|
||||
// 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")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
|
||||
106
service/billing.go
Normal file
106
service/billing.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
BillingSourceWallet = "wallet"
|
||||
BillingSourceSubscription = "subscription"
|
||||
)
|
||||
|
||||
// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
|
||||
// It also always pre-consumes token quota in quota units (same as legacy flow).
|
||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
if relayInfo == nil {
|
||||
return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||||
trySubscription := func() *types.NewAPIError {
|
||||
quotaType := 0
|
||||
// For total quota: consume preConsumedQuota quota units.
|
||||
subConsume := int64(preConsumedQuota)
|
||||
if subConsume <= 0 {
|
||||
subConsume = 1
|
||||
}
|
||||
|
||||
// Pre-consume token quota in quota units to keep token limits consistent.
|
||||
if preConsumedQuota > 0 {
|
||||
if err := PreConsumeTokenQuota(relayInfo, preConsumedQuota); err != nil {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
}
|
||||
|
||||
res, err := model.PreConsumeUserSubscription(relayInfo.RequestId, relayInfo.UserId, relayInfo.OriginModelName, quotaType, subConsume)
|
||||
if err != nil {
|
||||
// revert token pre-consume when subscription fails
|
||||
if preConsumedQuota > 0 && !relayInfo.IsPlayground {
|
||||
_ = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota)
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅预扣失败: %s", errMsg), types.ErrorCodeQueryDataError, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
relayInfo.BillingSource = BillingSourceSubscription
|
||||
relayInfo.SubscriptionId = res.UserSubscriptionId
|
||||
relayInfo.SubscriptionPreConsumed = res.PreConsumed
|
||||
relayInfo.SubscriptionPostDelta = 0
|
||||
relayInfo.SubscriptionAmountTotal = res.AmountTotal
|
||||
relayInfo.SubscriptionAmountUsedAfterPreConsume = res.AmountUsedAfter
|
||||
if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
|
||||
relayInfo.SubscriptionPlanId = planInfo.PlanId
|
||||
relayInfo.SubscriptionPlanTitle = planInfo.PlanTitle
|
||||
}
|
||||
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 使用订阅计费预扣:订阅=%d,token_quota=%d", relayInfo.UserId, res.PreConsumed, preConsumedQuota))
|
||||
return nil
|
||||
}
|
||||
|
||||
tryWallet := func() *types.NewAPIError {
|
||||
relayInfo.BillingSource = BillingSourceWallet
|
||||
relayInfo.SubscriptionId = 0
|
||||
relayInfo.SubscriptionPreConsumed = 0
|
||||
return PreConsumeQuota(c, preConsumedQuota, relayInfo)
|
||||
}
|
||||
|
||||
switch pref {
|
||||
case "subscription_only":
|
||||
return trySubscription()
|
||||
case "wallet_only":
|
||||
return tryWallet()
|
||||
case "wallet_first":
|
||||
if err := tryWallet(); err != nil {
|
||||
// only fallback for insufficient wallet quota
|
||||
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
|
||||
return trySubscription()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case "subscription_first":
|
||||
fallthrough
|
||||
default:
|
||||
if err := trySubscription(); err != nil {
|
||||
// fallback only when subscription not available/insufficient
|
||||
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
|
||||
return tryWallet()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -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
471
service/file_service.go
Normal 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"
|
||||
}
|
||||
@@ -73,9 +73,64 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
||||
other["admin_info"] = adminInfo
|
||||
appendRequestPath(ctx, relayInfo, other)
|
||||
appendRequestConversionChain(relayInfo, other)
|
||||
appendBillingInfo(relayInfo, other)
|
||||
return other
|
||||
}
|
||||
|
||||
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
}
|
||||
// billing_source: "wallet" or "subscription"
|
||||
if relayInfo.BillingSource != "" {
|
||||
other["billing_source"] = relayInfo.BillingSource
|
||||
}
|
||||
if relayInfo.UserSetting.BillingPreference != "" {
|
||||
other["billing_preference"] = relayInfo.UserSetting.BillingPreference
|
||||
}
|
||||
if relayInfo.BillingSource == "subscription" {
|
||||
if relayInfo.SubscriptionId != 0 {
|
||||
other["subscription_id"] = relayInfo.SubscriptionId
|
||||
}
|
||||
if relayInfo.SubscriptionPreConsumed > 0 {
|
||||
other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed
|
||||
}
|
||||
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
|
||||
if relayInfo.SubscriptionPostDelta != 0 {
|
||||
other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta
|
||||
}
|
||||
if relayInfo.SubscriptionPlanId != 0 {
|
||||
other["subscription_plan_id"] = relayInfo.SubscriptionPlanId
|
||||
}
|
||||
if relayInfo.SubscriptionPlanTitle != "" {
|
||||
other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle
|
||||
}
|
||||
// Compute "this request" subscription consumed + remaining
|
||||
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
|
||||
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
|
||||
if consumed < 0 {
|
||||
consumed = 0
|
||||
}
|
||||
if usedFinal < 0 {
|
||||
usedFinal = 0
|
||||
}
|
||||
if relayInfo.SubscriptionAmountTotal > 0 {
|
||||
remain := relayInfo.SubscriptionAmountTotal - usedFinal
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
other["subscription_total"] = relayInfo.SubscriptionAmountTotal
|
||||
other["subscription_used"] = usedFinal
|
||||
other["subscription_remain"] = remain
|
||||
}
|
||||
if consumed > 0 {
|
||||
other["subscription_consumed"] = consumed
|
||||
}
|
||||
// Wallet quota is not deducted when billed from subscription.
|
||||
other["wallet_quota_deducted"] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
@@ -15,17 +16,61 @@ import (
|
||||
)
|
||||
|
||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
// Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0)
|
||||
needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionId != 0 && relayInfo.SubscriptionPreConsumed > 0
|
||||
needRefundToken := relayInfo.FinalPreConsumedQuota != 0
|
||||
if !needRefundSub && !needRefundToken {
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, subscription=%d)",
|
||||
relayInfo.UserId,
|
||||
logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
|
||||
relayInfo.SubscriptionPreConsumed,
|
||||
))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
if relayInfoCopy.BillingSource == BillingSourceSubscription {
|
||||
if needRefundSub {
|
||||
if err := refundWithRetry(func() error {
|
||||
return model.RefundSubscriptionPreConsume(relayInfoCopy.RequestId)
|
||||
}); err != nil {
|
||||
common.SysLog("error refund subscription pre-consume: " + err.Error())
|
||||
}
|
||||
}
|
||||
// refund token quota only
|
||||
if needRefundToken && !relayInfoCopy.IsPlayground {
|
||||
_ = model.IncreaseTokenQuota(relayInfoCopy.TokenId, relayInfoCopy.TokenKey, relayInfoCopy.FinalPreConsumedQuota)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// wallet refund uses existing path (user quota + token quota)
|
||||
if needRefundToken {
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||
if err != nil {
|
||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func refundWithRetry(fn func() error) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
const maxAttempts = 3
|
||||
var lastErr error
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if err := fn(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if i < maxAttempts-1 {
|
||||
time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||
|
||||
@@ -503,13 +503,28 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
||||
|
||||
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) {
|
||||
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
// 1) Consume from wallet quota OR subscription item
|
||||
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
|
||||
if relayInfo.SubscriptionId == 0 {
|
||||
return errors.New("subscription id is missing")
|
||||
}
|
||||
delta := int64(quota)
|
||||
if delta != 0 {
|
||||
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
relayInfo.SubscriptionPostDelta += delta
|
||||
}
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
// Wallet
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !relayInfo.IsPlayground {
|
||||
|
||||
93
service/subscription_reset_task.go
Normal file
93
service/subscription_reset_task.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionResetTickInterval = 1 * time.Minute
|
||||
subscriptionResetBatchSize = 300
|
||||
subscriptionCleanupInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
subscriptionResetOnce sync.Once
|
||||
subscriptionResetRunning atomic.Bool
|
||||
subscriptionCleanupLast atomic.Int64
|
||||
)
|
||||
|
||||
func StartSubscriptionQuotaResetTask() {
|
||||
subscriptionResetOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
gopool.Go(func() {
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("subscription quota reset task started: tick=%s", subscriptionResetTickInterval))
|
||||
ticker := time.NewTicker(subscriptionResetTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runSubscriptionQuotaResetOnce()
|
||||
for range ticker.C {
|
||||
runSubscriptionQuotaResetOnce()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runSubscriptionQuotaResetOnce() {
|
||||
if !subscriptionResetRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer subscriptionResetRunning.Store(false)
|
||||
|
||||
ctx := context.Background()
|
||||
totalReset := 0
|
||||
totalExpired := 0
|
||||
for {
|
||||
n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalExpired += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription quota reset task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalReset += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
lastCleanup := time.Unix(subscriptionCleanupLast.Load(), 0)
|
||||
if time.Since(lastCleanup) >= subscriptionCleanupInterval {
|
||||
if _, err := model.CleanupSubscriptionPreConsumeRecords(7 * 24 * 3600); err == nil {
|
||||
subscriptionCleanupLast.Store(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
|
||||
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
231
types/file_source.go
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,7 @@ import Task from './pages/Task';
|
||||
import ModelPage from './pages/Model';
|
||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||
import Playground from './pages/Playground';
|
||||
import Subscription from './pages/Subscription';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
import Setup from './pages/Setup';
|
||||
@@ -117,6 +118,14 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/subscription'
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Subscription />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
|
||||
@@ -37,6 +37,7 @@ const routerMap = {
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
subscription: '/console/subscription',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
@@ -152,6 +153,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('订阅管理'),
|
||||
itemKey: 'subscription',
|
||||
to: '/subscription',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('模型管理'),
|
||||
itemKey: 'models',
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsActions;
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Popover,
|
||||
Divider,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatDuration(plan, t) {
|
||||
if (!plan) return '';
|
||||
const u = plan.duration_unit || 'month';
|
||||
if (u === 'custom') {
|
||||
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
|
||||
}
|
||||
const unitMap = {
|
||||
year: t('年'),
|
||||
month: t('月'),
|
||||
day: t('日'),
|
||||
hour: t('小时'),
|
||||
};
|
||||
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
||||
}
|
||||
|
||||
function formatResetPeriod(plan, t) {
|
||||
const period = plan?.quota_reset_period || 'never';
|
||||
if (period === 'daily') return t('每天');
|
||||
if (period === 'weekly') return t('每周');
|
||||
if (period === 'monthly') return t('每月');
|
||||
if (period === 'custom') {
|
||||
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return t('不重置');
|
||||
}
|
||||
|
||||
const renderPlanTitle = (text, record, t) => {
|
||||
const subtitle = record?.plan?.subtitle;
|
||||
const plan = record?.plan;
|
||||
const popoverContent = (
|
||||
<div style={{ width: 260 }}>
|
||||
<Text strong>{text}</Text>
|
||||
{subtitle && (
|
||||
<Text type='tertiary' style={{ display: 'block', marginTop: 4 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
<Divider margin={12} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Text type='tertiary'>{t('价格')}</Text>
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('总额度')}</Text>
|
||||
{plan?.total_amount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${plan.total_amount}`}>
|
||||
<Text>{renderQuota(plan.total_amount)}</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text>{t('不限')}</Text>
|
||||
)}
|
||||
<Text type='tertiary'>{t('升级分组')}</Text>
|
||||
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
|
||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||
<Text>
|
||||
{plan?.max_purchase_per_user > 0
|
||||
? plan.max_purchase_per_user
|
||||
: t('不限')}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('有效期')}</Text>
|
||||
<Text>{formatDuration(plan, t)}</Text>
|
||||
<Text type='tertiary'>{t('重置')}</Text>
|
||||
<Text>{formatResetPeriod(plan, t)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} position='rightTop' showArrow>
|
||||
<div style={{ cursor: 'pointer', maxWidth: 180 }}>
|
||||
<Text strong ellipsis={{ showTooltip: false }}>
|
||||
{text}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
ellipsis={{ showTooltip: false }}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPrice = (text) => {
|
||||
return (
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(text || 0), 2)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPurchaseLimit = (text, record, t) => {
|
||||
const limit = Number(record?.plan?.max_purchase_per_user || 0);
|
||||
return (
|
||||
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
|
||||
{limit > 0 ? limit : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDuration = (text, record, t) => {
|
||||
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||
};
|
||||
|
||||
const renderEnabled = (text, record, t) => {
|
||||
return text ? (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='danger' />}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTotalAmount = (text, record, t) => {
|
||||
const total = Number(record?.plan?.total_amount || 0);
|
||||
return (
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${total}`}>
|
||||
<span>{renderQuota(total)}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgradeGroup = (text, record, t) => {
|
||||
const group = record?.plan?.upgrade_group || '';
|
||||
return (
|
||||
<Text type={group ? 'secondary' : 'tertiary'}>
|
||||
{group ? group : t('不升级')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResetPeriod = (text, record, t) => {
|
||||
const period = record?.plan?.quota_reset_period || 'never';
|
||||
const isNever = period === 'never';
|
||||
return (
|
||||
<Text type={isNever ? 'tertiary' : 'secondary'}>
|
||||
{formatResetPeriod(record?.plan, t)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPaymentConfig = (text, record, t, enableEpay) => {
|
||||
const hasStripe = !!record?.plan?.stripe_price_id;
|
||||
const hasCreem = !!record?.plan?.creem_product_id;
|
||||
const hasEpay = !!enableEpay;
|
||||
|
||||
return (
|
||||
<Space spacing={4}>
|
||||
{hasStripe && (
|
||||
<Tag color='violet' shape='circle'>
|
||||
Stripe
|
||||
</Tag>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Tag color='cyan' shape='circle'>
|
||||
Creem
|
||||
</Tag>
|
||||
)}
|
||||
{hasEpay && (
|
||||
<Tag color='light-green' shape='circle'>
|
||||
{t('易支付')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
const isEnabled = record?.plan?.enabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEnabled) {
|
||||
Modal.confirm({
|
||||
title: t('确认禁用'),
|
||||
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, false),
|
||||
});
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: t('确认启用'),
|
||||
content: t('启用后套餐将在用户端展示。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, true),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={8}>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionsColumns = ({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['plan', 'id'],
|
||||
width: 60,
|
||||
render: (text) => <Text type='tertiary'>#{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
dataIndex: ['plan', 'title'],
|
||||
width: 200,
|
||||
render: (text, record) => renderPlanTitle(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: ['plan', 'price_amount'],
|
||||
width: 100,
|
||||
render: (text) => renderPrice(text),
|
||||
},
|
||||
{
|
||||
title: t('购买上限'),
|
||||
width: 90,
|
||||
render: (text, record) => renderPurchaseLimit(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('优先级'),
|
||||
dataIndex: ['plan', 'sort_order'],
|
||||
width: 80,
|
||||
render: (text) => <Text type='tertiary'>{Number(text || 0)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 100,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('重置'),
|
||||
width: 80,
|
||||
render: (text, record) => renderResetPeriod(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: ['plan', 'enabled'],
|
||||
width: 80,
|
||||
render: (text, record) => renderEnabled(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('支付渠道'),
|
||||
width: 180,
|
||||
render: (text, record) =>
|
||||
renderPaymentConfig(text, record, t, enableEpay),
|
||||
},
|
||||
{
|
||||
title: t('总额度'),
|
||||
width: 100,
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('升级分组'),
|
||||
width: 100,
|
||||
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<CalendarClock size={16} className='mr-2' />
|
||||
<Text>{t('订阅管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsDescription;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
||||
|
||||
const SubscriptionsTable = (subscriptionsData) => {
|
||||
const {
|
||||
plans,
|
||||
loading,
|
||||
compactMode,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
t,
|
||||
enableEpay,
|
||||
} = subscriptionsData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getSubscriptionsColumns({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
});
|
||||
}, [t, openEdit, setPlanEnabled, enableEpay]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={plans}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={false}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowKey={(row) => row?.plan?.id}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅套餐')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
size='middle'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsTable;
|
||||
103
web/src/components/table/subscriptions/index.jsx
Normal file
103
web/src/components/table/subscriptions/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import SubscriptionsTable from './SubscriptionsTable';
|
||||
import SubscriptionsActions from './SubscriptionsActions';
|
||||
import SubscriptionsDescription from './SubscriptionsDescription';
|
||||
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
|
||||
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const SubscriptionsPage = () => {
|
||||
const subscriptionsData = useSubscriptionsData();
|
||||
const isMobile = useIsMobile();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const enableEpay = !!statusState?.status?.enable_online_topup;
|
||||
|
||||
const {
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
closeEdit,
|
||||
refresh,
|
||||
openCreate,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddEditSubscriptionModal
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
editingPlan={editingPlan}
|
||||
placement={sheetPlacement}
|
||||
refresh={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type='type1'
|
||||
descriptionArea={
|
||||
<SubscriptionsDescription
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
{/* Mobile: actions first; Desktop: actions left */}
|
||||
<div className='order-1 md:order-0 w-full md:w-auto'>
|
||||
<SubscriptionsActions openCreate={openCreate} t={t} />
|
||||
</div>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
|
||||
closeIcon={null}
|
||||
// Mobile: banner below; Desktop: banner right
|
||||
className='!rounded-lg order-2 md:order-1'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: subscriptionsData.activePage,
|
||||
pageSize: subscriptionsData.pageSize,
|
||||
total: subscriptionsData.planCount,
|
||||
onPageChange: subscriptionsData.handlePageChange,
|
||||
onPageSizeChange: subscriptionsData.handlePageSizeChange,
|
||||
isMobile,
|
||||
t: subscriptionsData.t,
|
||||
})}
|
||||
t={t}
|
||||
>
|
||||
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsPage;
|
||||
@@ -0,0 +1,553 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCalendarClock,
|
||||
IconClose,
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Clock, RefreshCw } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const durationUnitOptions = [
|
||||
{ value: 'year', label: '年' },
|
||||
{ value: 'month', label: '月' },
|
||||
{ value: 'day', label: '日' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const resetPeriodOptions = [
|
||||
{ value: 'never', label: '不重置' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const AddEditSubscriptionModal = ({
|
||||
visible,
|
||||
handleClose,
|
||||
editingPlan,
|
||||
placement = 'left',
|
||||
refresh,
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
|
||||
|
||||
const getInitValues = () => ({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
price_amount: 0,
|
||||
currency: 'USD',
|
||||
duration_unit: 'month',
|
||||
duration_value: 1,
|
||||
custom_seconds: 0,
|
||||
quota_reset_period: 'never',
|
||||
quota_reset_custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
|
||||
const buildFormValues = () => {
|
||||
const base = getInitValues();
|
||||
if (editingPlan?.plan?.id === undefined) return base;
|
||||
const p = editingPlan.plan || {};
|
||||
return {
|
||||
...base,
|
||||
title: p.title || '',
|
||||
subtitle: p.subtitle || '',
|
||||
price_amount: Number(p.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_unit: p.duration_unit || 'month',
|
||||
duration_value: Number(p.duration_value || 1),
|
||||
custom_seconds: Number(p.custom_seconds || 0),
|
||||
quota_reset_period: p.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
||||
enabled: p.enabled !== false,
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||
total_amount: Number(
|
||||
quotaToDisplayAmount(p.total_amount || 0).toFixed(2),
|
||||
),
|
||||
upgrade_group: p.upgrade_group || '',
|
||||
stripe_price_id: p.stripe_price_id || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setGroupLoading(true);
|
||||
API.get('/api/group')
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setGroupOptions(res.data?.data || []);
|
||||
} else {
|
||||
setGroupOptions([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setGroupOptions([]))
|
||||
.finally(() => setGroupLoading(false));
|
||||
}, [visible]);
|
||||
|
||||
const submit = async (values) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
plan: {
|
||||
...values,
|
||||
price_amount: Number(values.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_value: Number(values.duration_value || 0),
|
||||
custom_seconds: Number(values.custom_seconds || 0),
|
||||
quota_reset_period: values.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds:
|
||||
values.quota_reset_period === 'custom'
|
||||
? Number(values.quota_reset_custom_seconds || 0)
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: displayAmountToQuota(values.total_amount),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
const res = await API.put(
|
||||
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
|
||||
payload,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/subscription/admin/plans', payload);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={formKey}
|
||||
initValues={buildFormValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCalendarClock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('基本信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('套餐的基本信息和定价')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: t('请输入套餐标题') },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='subtitle'
|
||||
label={t('套餐副标题')}
|
||||
placeholder={t('例如:适合轻度使用')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${t('原生额度')}:${displayAmountToQuota(
|
||||
values.total_amount,
|
||||
)}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='upgrade_group'
|
||||
label={t('升级分组')}
|
||||
showClear
|
||||
loading={groupLoading}
|
||||
placeholder={t('不升级')}
|
||||
extraText={t(
|
||||
'购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。',
|
||||
)}
|
||||
>
|
||||
<Select.Option value=''>{t('不升级')}</Select.Option>
|
||||
{(groupOptions || []).map((g) => (
|
||||
<Select.Option key={g} value={g}>
|
||||
{g}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
label={t('币种')}
|
||||
disabled
|
||||
extraText={t('由全站货币展示设置统一控制')}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='sort_order'
|
||||
label={t('排序')}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='max_purchase_per_user'
|
||||
label={t('购买上限')}
|
||||
min={0}
|
||||
precision={0}
|
||||
extraText={t('0 表示不限')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Switch
|
||||
field='enabled'
|
||||
label={t('启用状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 有效期设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='green'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<Clock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('有效期设置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('配置套餐的有效时长')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
required
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{values.duration_unit === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 额度重置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('额度重置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('支持周期性重置套餐权益额度')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='quota_reset_period'
|
||||
label={t('重置周期')}
|
||||
>
|
||||
{resetPeriodOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{values.quota_reset_period === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={60}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 第三方支付配置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('第三方支付配置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('Stripe/Creem 商品ID(可选)')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='stripe_price_id'
|
||||
label='Stripe PriceId'
|
||||
placeholder='price_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='creem_product_id'
|
||||
label='Creem ProductId'
|
||||
placeholder='prod_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEditSubscriptionModal;
|
||||
@@ -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('平台'),
|
||||
|
||||
@@ -211,6 +211,18 @@ function renderFirstUseTime(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBillingTag(record, t) {
|
||||
const other = getLogOther(record.other);
|
||||
if (other?.billing_source === 'subscription') {
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('订阅抵扣')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderModelName(record, copyText, t) {
|
||||
let other = getLogOther(record.other);
|
||||
let modelMapped =
|
||||
@@ -487,11 +499,20 @@ export const getLogsColumns = ({
|
||||
title: t('花费'),
|
||||
dataIndex: 'quota',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
<>{renderQuota(text, 6)}</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
|
||||
return <></>;
|
||||
}
|
||||
const other = getLogOther(record.other);
|
||||
const isSubscription = other?.billing_source === 'subscription';
|
||||
if (isSubscription) {
|
||||
// Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost.
|
||||
return (
|
||||
<Tooltip content={`${t('由订阅抵扣')}:${renderQuota(text, 6)}`}>
|
||||
<span>{renderBillingTag(record, t)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <>{renderQuota(text, 6)}</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -698,6 +719,10 @@ export const getLogsColumns = ({
|
||||
other?.is_system_prompt_overwritten,
|
||||
'openai',
|
||||
);
|
||||
// Do not add billing source here; keep details clean.
|
||||
const summary = [content, text ? `${t('详情')}:${text}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
@@ -705,7 +730,7 @@ export const getLogsColumns = ({
|
||||
}}
|
||||
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{content}
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -208,6 +208,7 @@ const renderOperations = (
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -216,6 +217,14 @@ const renderOperations = (
|
||||
}
|
||||
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('订阅管理'),
|
||||
onClick: () => showUserSubscriptionsModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 Passkey'),
|
||||
@@ -299,6 +308,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -355,6 +365,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
import UserSubscriptionsModal from './modals/UserSubscriptionsModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -61,6 +62,8 @@ const UsersTable = (usersData) => {
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
const [showUserSubscriptionsModal, setShowUserSubscriptionsModal] =
|
||||
useState(false);
|
||||
|
||||
// Modal handlers
|
||||
const showPromoteUserModal = (user) => {
|
||||
@@ -94,6 +97,11 @@ const UsersTable = (usersData) => {
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
const showUserSubscriptionsUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowUserSubscriptionsModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
const handlePromoteConfirm = () => {
|
||||
manageUser(modalUser.id, 'promote', modalUser);
|
||||
@@ -132,6 +140,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
showUserSubscriptionsModal: showUserSubscriptionsUserModal,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -143,6 +152,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
showUserSubscriptionsUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
@@ -242,6 +252,14 @@ const UsersTable = (usersData) => {
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UserSubscriptionsModal
|
||||
visible={showUserSubscriptionsModal}
|
||||
onCancel={() => setShowUserSubscriptionsModal(false)}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
onSuccess={() => refresh?.()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
123
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
123
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Select, Space, Typography } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BindSubscriptionModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedPlanId(null);
|
||||
loadPlans();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const bind = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/admin/bind', {
|
||||
user_id: user.id,
|
||||
plan_id: selectedPlanId,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('绑定成功'));
|
||||
onSuccess?.();
|
||||
onCancel?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('绑定失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('绑定订阅套餐')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={bind}
|
||||
confirmLoading={loading}
|
||||
maskClosable={false}
|
||||
centered
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }} spacing='medium'>
|
||||
<div className='text-sm'>
|
||||
<Text strong>{t('用户')}:</Text>
|
||||
<Text>{user?.username}</Text>
|
||||
<Text type='tertiary'> (ID: {user?.id})</Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={loading}
|
||||
filter
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。')}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BindSubscriptionModal;
|
||||
433
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
433
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Modal,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { convertUSDToCurrency } from '../../../../helpers/render';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function renderStatusTag(sub, t) {
|
||||
const now = Date.now() / 1000;
|
||||
const end = sub?.end_time || 0;
|
||||
const status = sub?.status || '';
|
||||
|
||||
const isExpiredByTime = end > 0 && end < now;
|
||||
const isActive = status === 'active' && !isExpiredByTime;
|
||||
if (isActive) {
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已作废')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const [subs, setSubs] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const id = p?.plan?.id;
|
||||
const title = p?.plan?.title;
|
||||
if (id) map.set(id, title || `#${id}`);
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const pagedSubs = useMemo(() => {
|
||||
const start = Math.max(0, (Number(currentPage || 1) - 1) * pageSize);
|
||||
const end = start + pageSize;
|
||||
return (subs || []).slice(start, end);
|
||||
}, [subs, currentPage]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${convertUSDToCurrency(
|
||||
Number(p?.plan?.price_amount || 0),
|
||||
2,
|
||||
)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setPlansLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserSubscriptions = async () => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/subscription/admin/users/${user.id}/subscriptions`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const next = res.data.data || [];
|
||||
setSubs(next);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setSelectedPlanId(null);
|
||||
setCurrentPage(1);
|
||||
loadPlans();
|
||||
loadUserSubscriptions();
|
||||
}, [visible]);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const createSubscription = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/subscription/admin/users/${user.id}/subscriptions`,
|
||||
{
|
||||
plan_id: selectedPlanId,
|
||||
},
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('新增成功'));
|
||||
setSelectedPlanId(null);
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('新增失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认作废'),
|
||||
content: t('作废后该订阅将立即失效,历史记录不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已作废'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('删除会彻底移除该订阅记录(含权益明细)。是否继续?'),
|
||||
centered: true,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/subscription/admin/user_subscriptions/${subId}`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已删除'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['subscription', 'id'],
|
||||
key: 'id',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
key: 'plan',
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const planId = sub?.plan_id;
|
||||
const title =
|
||||
planTitleMap.get(planId) || (planId ? `#${planId}` : '-');
|
||||
return (
|
||||
<div className='min-w-0'>
|
||||
<div className='font-medium truncate'>{title}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('来源')}: {sub?.source || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (_, record) => renderStatusTag(record?.subscription, t),
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
key: 'validity',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
return (
|
||||
<div className='text-xs text-gray-600'>
|
||||
<div>
|
||||
{t('开始')}: {formatTs(sub?.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('结束')}: {formatTs(sub?.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('总额度'),
|
||||
key: 'total',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const total = Number(sub?.amount_total || 0);
|
||||
const used = Number(sub?.amount_used || 0);
|
||||
return (
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? `${used}/${total}` : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'operate',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired =
|
||||
(sub?.end_time || 0) > 0 && (sub?.end_time || 0) < now;
|
||||
const isActive = sub?.status === 'active' && !isExpired;
|
||||
const isCancelled = sub?.status === 'cancelled';
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
theme='light'
|
||||
disabled={!isActive || isCancelled}
|
||||
onClick={() => invalidateSubscription(sub?.id)}
|
||||
>
|
||||
{t('作废')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={() => deleteSubscription(sub?.id)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [t, planTitleMap]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
visible={visible}
|
||||
placement='right'
|
||||
width={isMobile ? '100%' : 920}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
onCancel={onCancel}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Typography.Title heading={4} className='m-0'>
|
||||
{t('用户订阅管理')}
|
||||
</Typography.Title>
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{user?.username || '-'} (ID: {user?.id || '-'})
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className='p-4'>
|
||||
{/* 顶部操作栏:新增订阅 */}
|
||||
<div className='flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4'>
|
||||
<div className='flex gap-2 flex-1'>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={plansLoading}
|
||||
filter
|
||||
style={{ minWidth: isMobile ? undefined : 300, flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconPlusCircle />}
|
||||
loading={creating}
|
||||
onClick={createSubscription}
|
||||
>
|
||||
{t('新增订阅')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订阅列表 */}
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={pagedSubs}
|
||||
rowKey={(row) => row?.subscription?.id}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
hidePagination={false}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize,
|
||||
total: subs.length,
|
||||
pageSizeOpts: [10, 20, 50],
|
||||
showSizeChanger: false,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅记录')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
size='middle'
|
||||
/>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSubscriptionsModal;
|
||||
647
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
647
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
import {
|
||||
formatSubscriptionDuration,
|
||||
formatSubscriptionResetPeriod,
|
||||
} from '../../helpers/subscriptionFormat';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 过滤易支付方式
|
||||
function getEpayMethods(payMethods = []) {
|
||||
return (payMethods || []).filter(
|
||||
(m) => m?.type && m.type !== 'stripe' && m.type !== 'creem',
|
||||
);
|
||||
}
|
||||
|
||||
// 提交易支付表单
|
||||
function submitEpayForm({ url, params }) {
|
||||
const form = document.createElement('form');
|
||||
form.action = url;
|
||||
form.method = 'POST';
|
||||
const isSafari =
|
||||
navigator.userAgent.indexOf('Safari') > -1 &&
|
||||
navigator.userAgent.indexOf('Chrome') < 1;
|
||||
if (!isSafari) form.target = '_blank';
|
||||
Object.keys(params || {}).forEach((key) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = params[key];
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
const SubscriptionPlansCard = ({
|
||||
t,
|
||||
loading = false,
|
||||
plans = [],
|
||||
payMethods = [],
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
billingPreference,
|
||||
onChangeBillingPreference,
|
||||
activeSubscriptions = [],
|
||||
allSubscriptions = [],
|
||||
reloadSubscriptionSelf,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [selectedEpayMethod, setSelectedEpayMethod] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const epayMethods = useMemo(() => getEpayMethods(payMethods), [payMethods]);
|
||||
|
||||
const openBuy = (p) => {
|
||||
setSelectedPlan(p);
|
||||
setSelectedEpayMethod(epayMethods?.[0]?.type || '');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeBuy = () => {
|
||||
setOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setPaying(false);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await reloadSubscriptionSelf?.();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payStripe = async () => {
|
||||
if (!selectedPlan?.plan?.stripe_price_id) {
|
||||
showError(t('该套餐未配置 Stripe'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/stripe/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
window.open(res.data.data?.pay_link, '_blank');
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payCreem = async () => {
|
||||
if (!selectedPlan?.plan?.creem_product_id) {
|
||||
showError(t('该套餐未配置 Creem'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/creem/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
window.open(res.data.data?.checkout_url, '_blank');
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payEpay = async () => {
|
||||
if (!selectedEpayMethod) {
|
||||
showError(t('请选择支付方式'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/epay/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
payment_method: selectedEpayMethod,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
submitEpayForm({ url: res.data.url, params: res.data.data });
|
||||
showSuccess(t('已发起支付'));
|
||||
closeBuy();
|
||||
} else {
|
||||
const errorMsg =
|
||||
typeof res.data?.data === 'string'
|
||||
? res.data.data
|
||||
: res.data?.message || t('支付失败');
|
||||
showError(errorMsg);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当前订阅信息 - 支持多个订阅
|
||||
const hasActiveSubscription = activeSubscriptions.length > 0;
|
||||
const hasAnySubscription = allSubscriptions.length > 0;
|
||||
|
||||
const planPurchaseCountMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(allSubscriptions || []).forEach((sub) => {
|
||||
const planId = sub?.subscription?.plan_id;
|
||||
if (!planId) return;
|
||||
map.set(planId, (map.get(planId) || 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [allSubscriptions]);
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const plan = p?.plan;
|
||||
if (!plan?.id) return;
|
||||
map.set(plan.id, plan.title || '');
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const getPlanPurchaseCount = (planId) =>
|
||||
planPurchaseCountMap.get(planId) || 0;
|
||||
|
||||
// 计算单个订阅的剩余天数
|
||||
const getRemainingDays = (sub) => {
|
||||
if (!sub?.subscription?.end_time) return 0;
|
||||
const now = Date.now() / 1000;
|
||||
const remaining = sub.subscription.end_time - now;
|
||||
return Math.max(0, Math.ceil(remaining / 86400));
|
||||
};
|
||||
|
||||
// 计算单个订阅的使用进度
|
||||
const getUsagePercent = (sub) => {
|
||||
const total = Number(sub?.subscription?.amount_total || 0);
|
||||
const used = Number(sub?.subscription?.amount_used || 0);
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((used / total) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
||||
<Crown size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('订阅套餐')}</Text>
|
||||
<div className='text-xs'>{t('购买订阅获得模型额度/次数')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 扣费策略 - 右上角 */}
|
||||
<Select
|
||||
value={billingPreference}
|
||||
onChange={onChangeBillingPreference}
|
||||
size='small'
|
||||
optionList={[
|
||||
{ value: 'subscription_first', label: t('优先订阅') },
|
||||
{ value: 'wallet_first', label: t('优先钱包') },
|
||||
{ value: 'subscription_only', label: t('仅用订阅') },
|
||||
{ value: 'wallet_only', label: t('仅用钱包') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className='space-y-4'>
|
||||
{/* 我的订阅骨架屏 */}
|
||||
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Skeleton.Title active style={{ width: 100, height: 20 }} />
|
||||
<Skeleton.Button active style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton.Paragraph active rows={2} />
|
||||
</div>
|
||||
</Card>
|
||||
{/* 套餐列表骨架屏 */}
|
||||
<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 w-full h-full'
|
||||
bodyStyle={{ padding: 16 }}
|
||||
>
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: '60%', height: 24, marginBottom: 8 }}
|
||||
/>
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={1}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div className='text-center py-4'>
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: '40%', height: 32, margin: '0 auto' }}
|
||||
/>
|
||||
</div>
|
||||
<Skeleton.Paragraph active rows={3} style={{ marginTop: 12 }} />
|
||||
<Skeleton.Button
|
||||
active
|
||||
block
|
||||
style={{ marginTop: 16, height: 32 }}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space vertical style={{ width: '100%' }} spacing={8}>
|
||||
{/* 当前订阅状态 */}
|
||||
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{t('我的订阅')}</Text>
|
||||
{hasActiveSubscription ? (
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{activeSubscriptions.length} {t('个生效中')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('无生效')}
|
||||
</Tag>
|
||||
)}
|
||||
{allSubscriptions.length > activeSubscriptions.length && (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
||||
{t('个已过期')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={refreshing ? 'animate-spin' : ''}
|
||||
/>
|
||||
}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAnySubscription ? (
|
||||
<>
|
||||
<Divider margin={8} />
|
||||
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
|
||||
{allSubscriptions.map((sub, subIndex) => {
|
||||
const isLast = subIndex === allSubscriptions.length - 1;
|
||||
const subscription = sub.subscription;
|
||||
const totalAmount = Number(subscription?.amount_total || 0);
|
||||
const usedAmount = Number(subscription?.amount_used || 0);
|
||||
const remainAmount =
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0;
|
||||
const planTitle =
|
||||
planTitleMap.get(subscription?.plan_id) || '';
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired = (subscription?.end_time || 0) < now;
|
||||
const isActive =
|
||||
subscription?.status === 'active' && !isExpired;
|
||||
|
||||
return (
|
||||
<div key={subscription?.id || subIndex}>
|
||||
{/* 订阅概要 */}
|
||||
<div className='flex items-center justify-between text-xs mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{planTitle
|
||||
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
|
||||
: `${t('订阅')} #${subscription?.id}`}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className='text-gray-500'>
|
||||
{t('剩余')} {remainDays} {t('天')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{isActive ? t('至') : t('过期于')}{' '}
|
||||
{new Date(
|
||||
(subscription?.end_time || 0) * 1000,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip
|
||||
content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
|
||||
>
|
||||
<span>
|
||||
{renderQuota(usedAmount)}/
|
||||
{renderQuota(totalAmount)} · {t('剩余')}{' '}
|
||||
{renderQuota(remainAmount)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('已用')} {usagePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && <Divider margin={12} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('购买套餐后即可享受模型权益')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 可购买套餐 - 标准定价卡片 */}
|
||||
{plans.length > 0 ? (
|
||||
<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);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = Number(plan?.price_amount || 0);
|
||||
const convertedPrice = price * rate;
|
||||
const displayPrice = convertedPrice.toFixed(
|
||||
Number.isInteger(convertedPrice) ? 0 : 2,
|
||||
);
|
||||
const isPopular = index === 0 && plans.length > 1;
|
||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
|
||||
const totalLabel =
|
||||
totalAmount > 0
|
||||
? `${t('总额度')}: ${renderQuota(totalAmount)}`
|
||||
: `${t('总额度')}: ${t('不限')}`;
|
||||
const upgradeLabel = plan?.upgrade_group
|
||||
? `${t('升级分组')}: ${plan.upgrade_group}`
|
||||
: null;
|
||||
const resetLabel =
|
||||
formatSubscriptionResetPeriod(plan, t) === t('不重置')
|
||||
? null
|
||||
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
|
||||
const planBenefits = [
|
||||
{
|
||||
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
|
||||
},
|
||||
resetLabel ? { label: resetLabel } : null,
|
||||
totalAmount > 0
|
||||
? {
|
||||
label: totalLabel,
|
||||
tooltip: `${t('原生额度')}:${totalAmount}`,
|
||||
}
|
||||
: { label: totalLabel },
|
||||
limitLabel ? { label: limitLabel } : null,
|
||||
upgradeLabel ? { label: upgradeLabel } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan?.id}
|
||||
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 h-full flex flex-col'>
|
||||
{/* 推荐标签 */}
|
||||
{isPopular && (
|
||||
<div className='mb-2'>
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
<Sparkles size={10} className='mr-1' />
|
||||
{t('推荐')}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{/* 套餐名称 */}
|
||||
<div className='mb-3'>
|
||||
<Typography.Title
|
||||
heading={5}
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{plan?.title || t('订阅套餐')}
|
||||
</Typography.Title>
|
||||
{plan?.subtitle && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{plan.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格区域 */}
|
||||
<div className='py-2'>
|
||||
<div className='flex items-baseline justify-start'>
|
||||
<span className='text-xl font-bold text-purple-600'>
|
||||
{symbol}
|
||||
</span>
|
||||
<span className='text-3xl font-bold text-purple-600'>
|
||||
{displayPrice}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 套餐权益描述 */}
|
||||
<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'>
|
||||
<Badge dot type='tertiary' />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
if (!item.tooltip) {
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className='w-full flex justify-start'
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={item.label} content={item.tooltip}>
|
||||
<div className='w-full flex justify-start'>
|
||||
{content}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<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
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-400 text-sm py-4'>
|
||||
{t('暂无可购买套餐')}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* 购买确认弹窗 */}
|
||||
<SubscriptionPurchaseModal
|
||||
t={t}
|
||||
visible={open}
|
||||
onCancel={closeBuy}
|
||||
selectedPlan={selectedPlan}
|
||||
paying={paying}
|
||||
selectedEpayMethod={selectedEpayMethod}
|
||||
setSelectedEpayMethod={setSelectedEpayMethod}
|
||||
epayMethods={epayMethods}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
purchaseLimitInfo={
|
||||
selectedPlan?.plan?.id
|
||||
? {
|
||||
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
|
||||
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onPayStripe={payStripe}
|
||||
onPayCreem={payCreem}
|
||||
onPayEpay={payEpay}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPlansCard;
|
||||
@@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status';
|
||||
|
||||
import RechargeCard from './RechargeCard';
|
||||
import InvitationCard from './InvitationCard';
|
||||
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
||||
import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
@@ -87,6 +88,14 @@ const TopUp = () => {
|
||||
// 账单Modal状态
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
// 订阅相关
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
||||
const [billingPreference, setBillingPreference] =
|
||||
useState('subscription_first');
|
||||
const [activeSubscriptions, setActiveSubscriptions] = useState([]);
|
||||
const [allSubscriptions, setAllSubscriptions] = useState([]);
|
||||
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
@@ -240,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);
|
||||
@@ -284,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);
|
||||
@@ -313,6 +326,61 @@ const TopUp = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionPlans = async () => {
|
||||
setSubscriptionLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/plans');
|
||||
if (res.data?.success) {
|
||||
setSubscriptionPlans(res.data.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setSubscriptionPlans([]);
|
||||
} finally {
|
||||
setSubscriptionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionSelf = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/subscription/self');
|
||||
if (res.data?.success) {
|
||||
setBillingPreference(
|
||||
res.data.data?.billing_preference || 'subscription_first',
|
||||
);
|
||||
// Active subscriptions
|
||||
const activeSubs = res.data.data?.subscriptions || [];
|
||||
setActiveSubscriptions(activeSubs);
|
||||
// All subscriptions (including expired)
|
||||
const allSubs = res.data.data?.all_subscriptions || [];
|
||||
setAllSubscriptions(allSubs);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const updateBillingPreference = async (pref) => {
|
||||
const previousPref = billingPreference;
|
||||
setBillingPreference(pref);
|
||||
try {
|
||||
const res = await API.put('/api/subscription/self/preference', {
|
||||
billing_preference: pref,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
const normalizedPref =
|
||||
res.data?.data?.billing_preference || pref || previousPref;
|
||||
setBillingPreference(normalizedPref);
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取充值配置信息
|
||||
const getTopupInfo = async () => {
|
||||
try {
|
||||
@@ -479,6 +547,8 @@ const TopUp = () => {
|
||||
// 在 statusState 可用时获取充值信息
|
||||
useEffect(() => {
|
||||
getTopupInfo().then();
|
||||
getSubscriptionPlans().then();
|
||||
getSubscriptionSelf().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -662,60 +732,72 @@ const TopUp = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧充值区域 */}
|
||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
{/* 主布局区域 */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧 - 订阅套餐 */}
|
||||
<div className='lg:col-span-7'>
|
||||
<SubscriptionPlansCard
|
||||
t={t}
|
||||
loading={subscriptionLoading}
|
||||
plans={subscriptionPlans}
|
||||
payMethods={payMethods}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
billingPreference={billingPreference}
|
||||
onChangeBillingPreference={updateBillingPreference}
|
||||
activeSubscriptions={activeSubscriptions}
|
||||
allSubscriptions={allSubscriptions}
|
||||
reloadSubscriptionSelf={getSubscriptionSelf}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<div className='lg:col-span-5'>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧 - 账户充值 + 邀请奖励 */}
|
||||
<div className='lg:col-span-5 flex flex-col gap-6'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Modal,
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Crown, CalendarClock, Package } from 'lucide-react';
|
||||
import { SiStripe } from 'react-icons/si';
|
||||
import { IconCreditCard } from '@douyinfe/semi-icons';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { getCurrencyConfig } from '../../../helpers/render';
|
||||
import {
|
||||
formatSubscriptionDuration,
|
||||
formatSubscriptionResetPeriod,
|
||||
} from '../../../helpers/subscriptionFormat';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionPurchaseModal = ({
|
||||
t,
|
||||
visible,
|
||||
onCancel,
|
||||
selectedPlan,
|
||||
paying,
|
||||
selectedEpayMethod,
|
||||
setSelectedEpayMethod,
|
||||
epayMethods = [],
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
purchaseLimitInfo = null,
|
||||
onPayStripe,
|
||||
onPayCreem,
|
||||
onPayEpay,
|
||||
}) => {
|
||||
const plan = selectedPlan?.plan;
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = plan ? Number(plan.price_amount || 0) : 0;
|
||||
const convertedPrice = price * rate;
|
||||
const displayPrice = convertedPrice.toFixed(
|
||||
Number.isInteger(convertedPrice) ? 0 : 2,
|
||||
);
|
||||
// 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示
|
||||
const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id;
|
||||
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
||||
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
||||
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
||||
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
|
||||
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
|
||||
const purchaseLimitReached =
|
||||
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Crown className='mr-2' size={18} />
|
||||
{t('购买订阅套餐')}
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
size='small'
|
||||
centered
|
||||
>
|
||||
{plan ? (
|
||||
<div className='space-y-4 pb-10'>
|
||||
{/* 套餐信息 */}
|
||||
<Card className='!rounded-xl !border-0 bg-slate-50 dark:bg-slate-800'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('套餐名称')}:
|
||||
</Text>
|
||||
<Typography.Text
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
className='text-slate-900 dark:text-slate-100'
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{plan.title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('有效期')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<CalendarClock size={14} className='mr-1 text-slate-500' />
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionDuration(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{formatSubscriptionResetPeriod(plan, t) !== t('不重置') && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('重置周期')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionResetPeriod(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('总额度')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<Package size={14} className='mr-1 text-slate-500' />
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${totalAmount}`}>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{renderQuota(totalAmount)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{t('不限')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan?.upgrade_group ? (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('升级分组')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{plan.upgrade_group}
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<Divider margin={8} />
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('应付金额')}:
|
||||
</Text>
|
||||
<Text strong className='text-xl text-purple-600'>
|
||||
{symbol}
|
||||
{displayPrice}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 支付方式 */}
|
||||
{purchaseLimitReached && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasAnyPayment ? (
|
||||
<div className='space-y-3'>
|
||||
<Text size='small' type='tertiary'>
|
||||
{t('选择支付方式')}:
|
||||
</Text>
|
||||
|
||||
{/* Stripe / Creem */}
|
||||
{(hasStripe || hasCreem) && (
|
||||
<div className='flex gap-2'>
|
||||
{hasStripe && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<SiStripe size={14} color='#635BFF' />}
|
||||
onClick={onPayStripe}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Stripe
|
||||
</Button>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<IconCreditCard />}
|
||||
onClick={onPayCreem}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Creem
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 易支付 */}
|
||||
{hasEpay && (
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
value={selectedEpayMethod}
|
||||
onChange={setSelectedEpayMethod}
|
||||
style={{ flex: 1 }}
|
||||
size='default'
|
||||
placeholder={t('选择支付方式')}
|
||||
optionList={epayMethods.map((m) => ({
|
||||
value: m.type,
|
||||
label: m.name || m.type,
|
||||
}))}
|
||||
disabled={purchaseLimitReached}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={onPayEpay}
|
||||
loading={paying}
|
||||
disabled={!selectedEpayMethod || purchaseLimitReached}
|
||||
>
|
||||
{t('支付')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('管理员未开启在线支付功能,请联系管理员配置。')}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPurchaseModal;
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Empty,
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -49,6 +50,7 @@ const STATUS_CONFIG = {
|
||||
// 支付方式映射
|
||||
const PAYMENT_METHOD_MAP = {
|
||||
stripe: 'Stripe',
|
||||
creem: 'Creem',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信',
|
||||
};
|
||||
@@ -150,6 +152,11 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||
};
|
||||
|
||||
const isSubscriptionTopup = (record) => {
|
||||
const tradeNo = (record?.trade_no || '').toLowerCase();
|
||||
return Number(record?.amount || 0) === 0 && tradeNo.startsWith('sub');
|
||||
};
|
||||
|
||||
// 检查是否为管理员
|
||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||
|
||||
@@ -171,12 +178,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount) => (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
),
|
||||
render: (amount, record) => {
|
||||
if (isSubscriptionTopup(record)) {
|
||||
return (
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
{t('订阅套餐')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('支付金额'),
|
||||
|
||||
25
web/src/helpers/quota.js
Normal file
25
web/src/helpers/quota.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getCurrencyConfig } from './render';
|
||||
|
||||
export const getQuotaPerUnit = () => {
|
||||
const raw = parseFloat(localStorage.getItem('quota_per_unit') || '1');
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||||
};
|
||||
|
||||
export const quotaToDisplayAmount = (quota) => {
|
||||
const q = Number(quota || 0);
|
||||
if (!Number.isFinite(q) || q <= 0) return 0;
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return q;
|
||||
const usd = q / getQuotaPerUnit();
|
||||
if (type === 'USD') return usd;
|
||||
return usd * (rate || 1);
|
||||
};
|
||||
|
||||
export const displayAmountToQuota = (amount) => {
|
||||
const val = Number(amount || 0);
|
||||
if (!Number.isFinite(val) || val <= 0) return 0;
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return Math.round(val);
|
||||
const usd = type === 'USD' ? val : val / (rate || 1);
|
||||
return Math.round(usd * getQuotaPerUnit());
|
||||
};
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
CircleUser,
|
||||
Package,
|
||||
Server,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
@@ -117,6 +118,8 @@ export function getLucideIcon(key, selected = false) {
|
||||
return <Package {...commonProps} color={iconColor} />;
|
||||
case 'deployment':
|
||||
return <Server {...commonProps} color={iconColor} />;
|
||||
case 'subscription':
|
||||
return <CalendarClock {...commonProps} color={iconColor} />;
|
||||
case 'setting':
|
||||
return <Settings {...commonProps} color={iconColor} />;
|
||||
default:
|
||||
@@ -602,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 {
|
||||
@@ -698,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) => {
|
||||
|
||||
34
web/src/helpers/subscriptionFormat.js
Normal file
34
web/src/helpers/subscriptionFormat.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export function formatSubscriptionDuration(plan, t) {
|
||||
const unit = plan?.duration_unit || 'month';
|
||||
const value = plan?.duration_value || 1;
|
||||
const unitLabels = {
|
||||
year: t('年'),
|
||||
month: t('个月'),
|
||||
day: t('天'),
|
||||
hour: t('小时'),
|
||||
custom: t('自定义'),
|
||||
};
|
||||
if (unit === 'custom') {
|
||||
const seconds = plan?.custom_seconds || 0;
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return `${value} ${unitLabels[unit] || unit}`;
|
||||
}
|
||||
|
||||
export function formatSubscriptionResetPeriod(plan, t) {
|
||||
const period = plan?.quota_reset_period || 'never';
|
||||
if (period === 'never') return t('不重置');
|
||||
if (period === 'daily') return t('每天');
|
||||
if (period === 'weekly') return t('每周');
|
||||
if (period === 'monthly') return t('每月');
|
||||
if (period === 'custom') {
|
||||
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return t('不重置');
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const DEFAULT_ADMIN_CONFIG = {
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
|
||||
166
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
166
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API, showError, showSuccess } from '../../helpers';
|
||||
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||
|
||||
export const useSubscriptionsData = () => {
|
||||
const { t } = useTranslation();
|
||||
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
|
||||
|
||||
// State management
|
||||
const [allPlans, setAllPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Pagination (client-side for now)
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
// Drawer states
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState(null);
|
||||
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
|
||||
|
||||
// Load subscription plans
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
const next = res.data.data || [];
|
||||
setAllPlans(next);
|
||||
|
||||
// Keep page in range after data changes
|
||||
const totalPages = Math.max(1, Math.ceil(next.length / pageSize));
|
||||
setActivePage((p) => Math.min(p || 1, totalPages));
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh data
|
||||
const refresh = async () => {
|
||||
await loadPlans();
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setActivePage(page);
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size) => {
|
||||
setPageSize(size);
|
||||
setActivePage(1);
|
||||
};
|
||||
|
||||
// Update plan enabled status (single endpoint)
|
||||
const setPlanEnabled = async (planRecordOrId, enabled) => {
|
||||
const planId =
|
||||
typeof planRecordOrId === 'number'
|
||||
? planRecordOrId
|
||||
: planRecordOrId?.plan?.id;
|
||||
if (!planId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.patch(`/api/subscription/admin/plans/${planId}`, {
|
||||
enabled: !!enabled,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(enabled ? t('已启用') : t('已禁用'));
|
||||
await loadPlans();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modal control functions
|
||||
const closeEdit = () => {
|
||||
setShowEdit(false);
|
||||
setEditingPlan(null);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setSheetPlacement('left');
|
||||
setEditingPlan(null);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
const openEdit = (planRecord) => {
|
||||
setSheetPlacement('right');
|
||||
setEditingPlan(planRecord);
|
||||
setShowEdit(true);
|
||||
};
|
||||
|
||||
// Initialize data on component mount
|
||||
useEffect(() => {
|
||||
loadPlans();
|
||||
}, []);
|
||||
|
||||
const planCount = allPlans.length;
|
||||
const plans = allPlans.slice(
|
||||
Math.max(0, (activePage - 1) * pageSize),
|
||||
Math.max(0, (activePage - 1) * pageSize) + pageSize,
|
||||
);
|
||||
|
||||
return {
|
||||
// Data state
|
||||
plans,
|
||||
planCount,
|
||||
loading,
|
||||
|
||||
// Modal state
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
setShowEdit,
|
||||
setEditingPlan,
|
||||
|
||||
// UI state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Pagination
|
||||
activePage,
|
||||
pageSize,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
|
||||
// Actions
|
||||
loadPlans,
|
||||
setPlanEnabled,
|
||||
refresh,
|
||||
closeEdit,
|
||||
openCreate,
|
||||
openEdit,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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('语音输入'),
|
||||
@@ -533,6 +541,55 @@ export const useLogsData = () => {
|
||||
value: other.request_path,
|
||||
});
|
||||
}
|
||||
if (other?.billing_source === 'subscription') {
|
||||
const planId = other?.subscription_plan_id;
|
||||
const planTitle = other?.subscription_plan_title || '';
|
||||
const subscriptionId = other?.subscription_id;
|
||||
const unit = t('额度');
|
||||
const pre = other?.subscription_pre_consumed ?? 0;
|
||||
const postDelta = other?.subscription_post_delta ?? 0;
|
||||
const finalConsumed = other?.subscription_consumed ?? pre + postDelta;
|
||||
const remain = other?.subscription_remain;
|
||||
const total = other?.subscription_total;
|
||||
// Use multiple Description items to avoid an overlong single line.
|
||||
if (planId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅套餐'),
|
||||
value: `#${planId} ${planTitle}`.trim(),
|
||||
});
|
||||
}
|
||||
if (subscriptionId) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅实例'),
|
||||
value: `#${subscriptionId}`,
|
||||
});
|
||||
}
|
||||
const settlementLines = [
|
||||
`${t('预扣')}:${pre} ${unit}`,
|
||||
`${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`,
|
||||
`${t('最终抵扣')}:${finalConsumed} ${unit}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
expandDataLocal.push({
|
||||
key: t('订阅结算'),
|
||||
value: (
|
||||
<div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>
|
||||
),
|
||||
});
|
||||
if (remain !== undefined && total !== undefined) {
|
||||
expandDataLocal.push({
|
||||
key: t('订阅剩余'),
|
||||
value: `${remain}/${total} ${unit}`,
|
||||
});
|
||||
}
|
||||
expandDataLocal.push({
|
||||
key: t('订阅说明'),
|
||||
value: t(
|
||||
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
|
||||
),
|
||||
});
|
||||
}
|
||||
if (isAdminUser) {
|
||||
expandDataLocal.push({
|
||||
key: t('请求转换'),
|
||||
@@ -571,6 +628,7 @@ export const useLogsData = () => {
|
||||
end_timestamp,
|
||||
channel,
|
||||
group,
|
||||
request_id,
|
||||
logType: formLogType,
|
||||
} = getFormValues();
|
||||
|
||||
@@ -584,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);
|
||||
|
||||
@@ -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}}",
|
||||
@@ -2461,7 +2500,6 @@
|
||||
"重新生成备用码失败": "Failed to regenerate backup codes",
|
||||
"重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。": "Regenerating backup codes will invalidate existing backup codes. Please ensure you have saved the current backup codes.",
|
||||
"重绘": "Vary",
|
||||
"重置": "Reset",
|
||||
"重置 2FA": "Reset Two-Factor Authentication",
|
||||
"重置 Passkey": "Reset Passkey",
|
||||
"重置为默认": "Reset to Default",
|
||||
@@ -2602,6 +2640,132 @@
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "After closing, this notice will no longer be shown (only for this browser). Are you sure you want to close it?",
|
||||
"关闭提示": "Close notice",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Note: Tests on this page use non-streaming requests. If a channel only supports streaming responses, tests may fail. Please rely on actual usage.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Notice: Endpoint mapping is for Model Marketplace display only and does not affect real model invocation. To configure real invocation, please go to Channel Management."
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Notice: Endpoint mapping is for Model Marketplace display only and does not affect real model invocation. To configure real invocation, please go to Channel Management.",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Stripe/Creem products must be created on the third-party platform and the ID filled in",
|
||||
"暂无订阅套餐": "No subscription plans",
|
||||
"订阅管理": "Subscription Management",
|
||||
"订阅套餐管理": "Subscription Plan Management",
|
||||
"新建套餐": "Create Plan",
|
||||
"套餐": "Plan",
|
||||
"支付渠道": "Payment Channels",
|
||||
"购买上限": "Purchase Limit",
|
||||
"有效期": "Validity",
|
||||
"重置": "Reset",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "After disabling, it will no longer be shown to users, but historical orders are not affected. Continue?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "After enabling, the plan will be shown to users. Continue?",
|
||||
"更新套餐信息": "Update Plan Info",
|
||||
"创建新的订阅套餐": "Create a New Subscription Plan",
|
||||
"套餐的基本信息和定价": "Basic plan info and pricing",
|
||||
"套餐标题": "Plan Title",
|
||||
"请输入套餐标题": "Please enter plan title",
|
||||
"套餐副标题": "Plan Subtitle",
|
||||
"例如:适合轻度使用": "e.g.: Suitable for light usage",
|
||||
"请输入金额": "Please enter amount",
|
||||
"请输入总额度": "Please enter total quota",
|
||||
"0 表示不限": "0 means unlimited",
|
||||
"原生额度": "Raw quota",
|
||||
"升级分组": "Upgrade Group",
|
||||
"不升级": "No upgrade",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "Purchasing or manually adding a subscription will upgrade to this group. When the plan expires or is invalidated/deleted, it will revert to the previous group. The rollback is not immediate and usually takes a few minutes.",
|
||||
"币种": "Currency",
|
||||
"由全站货币展示设置统一控制": "Controlled by the site-wide currency display settings",
|
||||
"排序": "Sort Order",
|
||||
"启用状态": "Enabled Status",
|
||||
"有效期设置": "Validity Settings",
|
||||
"配置套餐的有效时长": "Configure the plan validity duration",
|
||||
"有效期单位": "Validity Unit",
|
||||
"自定义秒数": "Custom seconds",
|
||||
"请输入秒数": "Please enter seconds",
|
||||
"有效期数值": "Validity Value",
|
||||
"额度重置": "Quota Reset",
|
||||
"支持周期性重置套餐权益额度": "Supports periodic reset of plan quota",
|
||||
"重置周期": "Reset Period",
|
||||
"第三方支付配置": "Third-party Payment Configuration",
|
||||
"Stripe/Creem 商品ID(可选)": "Stripe/Creem Product ID (optional)",
|
||||
"生效": "Active",
|
||||
"已作废": "Invalidated",
|
||||
"用户订阅管理": "User Subscription Management",
|
||||
"选择订阅套餐": "Select subscription plan",
|
||||
"新增订阅": "Add subscription",
|
||||
"暂无订阅记录": "No subscription records",
|
||||
"来源": "Source",
|
||||
"开始": "Start",
|
||||
"结束": "End",
|
||||
"作废": "Invalidate",
|
||||
"确认作废": "Confirm invalidation",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "After invalidation, the subscription becomes invalid immediately. History is not affected. Continue?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Deletion will permanently remove this subscription record (including benefit details). Continue?",
|
||||
"绑定订阅套餐": "Bind Subscription Plan",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "After binding, a user subscription is created immediately (no payment required); validity follows the plan configuration.",
|
||||
"订阅套餐": "Subscription Plans",
|
||||
"购买订阅获得模型额度/次数": "Purchase a subscription to get model quota/usage",
|
||||
"优先订阅": "Subscription first",
|
||||
"优先钱包": "Wallet first",
|
||||
"仅用订阅": "Subscription only",
|
||||
"仅用钱包": "Wallet only",
|
||||
"我的订阅": "My Subscriptions",
|
||||
"个生效中": "active",
|
||||
"无生效": "No active",
|
||||
"个已过期": "expired",
|
||||
"订阅": "Subscription",
|
||||
"至": "until",
|
||||
"过期于": "Expires at",
|
||||
"购买套餐后即可享受模型权益": "Enjoy model benefits after purchasing a plan",
|
||||
"限购": "Limit",
|
||||
"推荐": "Recommended",
|
||||
"已达到购买上限": "Purchase limit reached",
|
||||
"已达上限": "Limit reached",
|
||||
"立即订阅": "Subscribe now",
|
||||
"暂无可购买套餐": "No plans available for purchase",
|
||||
"该套餐未配置 Stripe": "This plan is not configured for Stripe",
|
||||
"已打开支付页面": "Payment page opened",
|
||||
"支付失败": "Payment failed",
|
||||
"该套餐未配置 Creem": "This plan is not configured for Creem",
|
||||
"已发起支付": "Payment initiated",
|
||||
"购买订阅套餐": "Purchase Subscription Plan",
|
||||
"套餐名称": "Plan Name",
|
||||
"应付金额": "Amount Due",
|
||||
"支付": "Pay",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2463,7 +2463,6 @@
|
||||
"重新生成备用码失败": "Échec de la régénération des codes de sauvegarde",
|
||||
"重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。": "La régénération des codes de sauvegarde invalidera les codes de sauvegarde existants. Veuillez vous assurer que vous avez enregistré les codes de sauvegarde actuels.",
|
||||
"重绘": "Varier",
|
||||
"重置": "Réinitialiser",
|
||||
"重置 2FA": "Réinitialiser 2FA",
|
||||
"重置 Passkey": "Réinitialiser le Passkey",
|
||||
"重置为默认": "Réinitialiser aux valeurs par défaut",
|
||||
@@ -2604,6 +2603,92 @@
|
||||
"格式化 JSON": "Formater le JSON",
|
||||
"关闭提示": "Fermer l’avertissement",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Remarque : les tests sur cette page utilisent des requêtes non-streaming. Si un canal ne prend en charge que les réponses en streaming, les tests peuvent échouer. Veuillez vous référer à l’usage réel.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Remarque : la correspondance des endpoints sert uniquement à l’affichage dans la place de marché des modèles et n’affecte pas l’invocation réelle. Pour configurer l’invocation réelle, veuillez aller dans « Gestion des canaux »."
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Remarque : la correspondance des endpoints sert uniquement à l’affichage dans la place de marché des modèles et n’affecte pas l’invocation réelle. Pour configurer l’invocation réelle, veuillez aller dans « Gestion des canaux ».",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Les produits Stripe/Creem doivent être créés sur la plateforme tierce et l'ID doit être renseigné",
|
||||
"暂无订阅套餐": "Aucun plan d'abonnement",
|
||||
"订阅管理": "Gestion des abonnements",
|
||||
"订阅套餐管理": "Gestion des plans d'abonnement",
|
||||
"新建套餐": "Créer un plan",
|
||||
"套餐": "Plan",
|
||||
"支付渠道": "Canaux de paiement",
|
||||
"购买上限": "Limite d'achat",
|
||||
"有效期": "Validité",
|
||||
"重置": "Réinitialisation",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "Après désactivation, il ne sera plus affiché côté utilisateur, mais les commandes historiques ne sont pas affectées. Continuer ?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "Après activation, le plan sera affiché côté utilisateur. Continuer ?",
|
||||
"更新套餐信息": "Mettre à jour le plan",
|
||||
"创建新的订阅套餐": "Créer un nouveau plan d'abonnement",
|
||||
"套餐的基本信息和定价": "Informations de base et tarification du plan",
|
||||
"套餐标题": "Titre du plan",
|
||||
"请输入套餐标题": "Veuillez saisir le titre du plan",
|
||||
"套餐副标题": "Sous-titre du plan",
|
||||
"例如:适合轻度使用": "Ex. : Convient à un usage léger",
|
||||
"请输入金额": "Veuillez saisir le montant",
|
||||
"请输入总额度": "Veuillez saisir le quota total",
|
||||
"0 表示不限": "0 signifie illimité",
|
||||
"原生额度": "Quota brut",
|
||||
"升级分组": "Groupe de mise à niveau",
|
||||
"不升级": "Pas de mise à niveau",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "L'achat ou l'ajout manuel d'un abonnement mettra à niveau vers ce groupe. À l'expiration ou en cas d'invalidation/suppression, il reviendra au groupe précédent. Le retour n'est pas immédiat et prend généralement quelques minutes.",
|
||||
"币种": "Devise",
|
||||
"由全站货币展示设置统一控制": "Contrôlé par les paramètres globaux d'affichage des devises",
|
||||
"排序": "Ordre",
|
||||
"启用状态": "Statut d'activation",
|
||||
"有效期设置": "Paramètres de validité",
|
||||
"配置套餐的有效时长": "Configurer la durée de validité du plan",
|
||||
"有效期单位": "Unité de validité",
|
||||
"自定义秒数": "Secondes personnalisées",
|
||||
"请输入秒数": "Veuillez saisir le nombre de secondes",
|
||||
"有效期数值": "Valeur de validité",
|
||||
"额度重置": "Réinitialisation du quota",
|
||||
"支持周期性重置套餐权益额度": "Prend en charge la réinitialisation périodique du quota du plan",
|
||||
"重置周期": "Période de réinitialisation",
|
||||
"第三方支付配置": "Configuration des paiements tiers",
|
||||
"Stripe/Creem 商品ID(可选)": "ID produit Stripe/Creem (optionnel)",
|
||||
"生效": "Actif",
|
||||
"已作废": "Invalidé",
|
||||
"用户订阅管理": "Gestion des abonnements utilisateur",
|
||||
"选择订阅套餐": "Sélectionner un plan d'abonnement",
|
||||
"新增订阅": "Ajouter un abonnement",
|
||||
"暂无订阅记录": "Aucun enregistrement d'abonnement",
|
||||
"来源": "Source",
|
||||
"开始": "Début",
|
||||
"结束": "Fin",
|
||||
"作废": "Invalider",
|
||||
"确认作废": "Confirmer l'invalidation",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Après invalidation, l'abonnement devient immédiatement invalide. L'historique n'est pas affecté. Continuer ?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "La suppression retirera définitivement cet enregistrement d'abonnement (y compris les détails des avantages). Continuer ?",
|
||||
"绑定订阅套餐": "Lier un plan d'abonnement",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "Après liaison, un abonnement utilisateur est créé immédiatement (sans paiement) ; la validité suit la configuration du plan.",
|
||||
"订阅套餐": "Plans d'abonnement",
|
||||
"购买订阅获得模型额度/次数": "Acheter un abonnement pour obtenir des quotas/usages de modèles",
|
||||
"优先订阅": "Abonnement en priorité",
|
||||
"优先钱包": "Portefeuille en priorité",
|
||||
"仅用订阅": "Abonnement uniquement",
|
||||
"仅用钱包": "Portefeuille uniquement",
|
||||
"我的订阅": "Mes abonnements",
|
||||
"个生效中": "actifs",
|
||||
"无生效": "Aucun actif",
|
||||
"个已过期": "expirés",
|
||||
"订阅": "Abonnement",
|
||||
"至": "jusqu'à",
|
||||
"过期于": "Expire le",
|
||||
"购买套餐后即可享受模型权益": "Profitez des avantages du modèle après l'achat d'un plan",
|
||||
"限购": "Limite",
|
||||
"推荐": "Recommandé",
|
||||
"已达到购买上限": "Limite d'achat atteinte",
|
||||
"已达上限": "Limite atteinte",
|
||||
"立即订阅": "S'abonner maintenant",
|
||||
"暂无可购买套餐": "Aucun plan disponible à l'achat",
|
||||
"该套餐未配置 Stripe": "Ce plan n'est pas configuré pour Stripe",
|
||||
"已打开支付页面": "Page de paiement ouverte",
|
||||
"支付失败": "Paiement échoué",
|
||||
"该套餐未配置 Creem": "Ce plan n'est pas configuré pour Creem",
|
||||
"已发起支付": "Paiement initié",
|
||||
"购买订阅套餐": "Acheter un plan d'abonnement",
|
||||
"套餐名称": "Nom du plan",
|
||||
"应付金额": "Montant à payer",
|
||||
"支付": "Payer",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "Le paiement en ligne n'est pas activé par l'administrateur. Veuillez contacter l'administrateur."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2446,7 +2446,6 @@
|
||||
"重新生成备用码失败": "バックアップコードの再生成に失敗しました",
|
||||
"重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。": "バックアップコードを再生成すると、既存のバックアップコードは無効になります。現在のバックアップコードを保存済みであることをご確認ください。",
|
||||
"重绘": "再生成",
|
||||
"重置": "リセット",
|
||||
"重置 2FA": "2要素認証のリセット",
|
||||
"重置 Passkey": "Passkeyリセット",
|
||||
"重置为默认": "デフォルトへのリセット",
|
||||
@@ -2587,6 +2586,92 @@
|
||||
"格式化 JSON": "JSON を整形",
|
||||
"关闭提示": "お知らせを閉じる",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "注意: このページのテストは非ストリーミングリクエストです。チャネルがストリーミング応答のみ対応の場合、テストが失敗することがあります。実際の利用結果を優先してください。",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。"
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "注意: エンドポイントマッピングは「モデル広場」での表示専用で、実際の呼び出しには影響しません。実際の呼び出し設定は「チャネル管理」で行ってください。",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Stripe/Creem の商品は外部プラットフォームで作成し、ID を入力してください",
|
||||
"暂无订阅套餐": "利用可能なサブスクリプションプランがありません",
|
||||
"订阅管理": "サブスクリプション管理",
|
||||
"订阅套餐管理": "サブスクリプションプラン管理",
|
||||
"新建套餐": "プラン作成",
|
||||
"套餐": "プラン",
|
||||
"支付渠道": "決済チャネル",
|
||||
"购买上限": "購入上限",
|
||||
"有效期": "有効期限",
|
||||
"重置": "リセット",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "無効化するとユーザー側に表示されなくなりますが、過去の注文には影響しません。続行しますか?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "有効化するとユーザー側に表示されます。続行しますか?",
|
||||
"更新套餐信息": "プラン情報を更新",
|
||||
"创建新的订阅套餐": "新しいサブスクリプションプランを作成",
|
||||
"套餐的基本信息和定价": "プランの基本情報と価格",
|
||||
"套餐标题": "プラン名",
|
||||
"请输入套餐标题": "プラン名を入力してください",
|
||||
"套餐副标题": "プランのサブタイトル",
|
||||
"例如:适合轻度使用": "例:軽めの利用に最適",
|
||||
"请输入金额": "金額を入力してください",
|
||||
"请输入总额度": "総クォータを入力してください",
|
||||
"0 表示不限": "0 は無制限を意味します",
|
||||
"原生额度": "生クォータ",
|
||||
"升级分组": "アップグレードグループ",
|
||||
"不升级": "アップグレードしない",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "購入または手動での追加によりこのグループにアップグレードされます。プランの失効/期限切れ、無効化/削除後は元のグループに戻ります。反映には数分かかる場合があります。",
|
||||
"币种": "通貨",
|
||||
"由全站货币展示设置统一控制": "サイト全体の通貨表示設定で統一して管理",
|
||||
"排序": "並び順",
|
||||
"启用状态": "有効状態",
|
||||
"有效期设置": "有効期限設定",
|
||||
"配置套餐的有效时长": "プランの有効期間を設定",
|
||||
"有效期单位": "有効期限の単位",
|
||||
"自定义秒数": "秒数を指定",
|
||||
"请输入秒数": "秒数を入力してください",
|
||||
"有效期数值": "有効期限の値",
|
||||
"额度重置": "クォータリセット",
|
||||
"支持周期性重置套餐权益额度": "プランのクォータを定期的にリセット可能",
|
||||
"重置周期": "リセット周期",
|
||||
"第三方支付配置": "サードパーティ決済設定",
|
||||
"Stripe/Creem 商品ID(可选)": "Stripe/Creem 商品ID(任意)",
|
||||
"生效": "有効",
|
||||
"已作废": "無効化済み",
|
||||
"用户订阅管理": "ユーザーサブスクリプション管理",
|
||||
"选择订阅套餐": "サブスクリプションプランを選択",
|
||||
"新增订阅": "サブスクリプションを追加",
|
||||
"暂无订阅记录": "サブスクリプション記録がありません",
|
||||
"来源": "ソース",
|
||||
"开始": "開始",
|
||||
"结束": "終了",
|
||||
"作废": "無効化",
|
||||
"确认作废": "無効化の確認",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "無効化するとこのサブスクリプションは直ちに失効します。履歴には影響しません。続行しますか?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "削除するとこのサブスクリプション記録(特典詳細を含む)が完全に削除されます。続行しますか?",
|
||||
"绑定订阅套餐": "サブスクリプションプランを紐付け",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "紐付け後、ユーザーサブスクリプションが即時に作成されます(支払い不要)。有効期限はプラン設定に従います。",
|
||||
"订阅套餐": "サブスクリプションプラン",
|
||||
"购买订阅获得模型额度/次数": "サブスクリプション購入でモデルのクォータ/回数を取得",
|
||||
"优先订阅": "サブスクリプション優先",
|
||||
"优先钱包": "ウォレット優先",
|
||||
"仅用订阅": "サブスクリプションのみ",
|
||||
"仅用钱包": "ウォレットのみ",
|
||||
"我的订阅": "私のサブスクリプション",
|
||||
"个生效中": "件有効中",
|
||||
"无生效": "有効なし",
|
||||
"个已过期": "件期限切れ",
|
||||
"订阅": "サブスクリプション",
|
||||
"至": "まで",
|
||||
"过期于": "有効期限",
|
||||
"购买套餐后即可享受模型权益": "プラン購入後にモデル特典を利用できます",
|
||||
"限购": "購入制限",
|
||||
"推荐": "おすすめ",
|
||||
"已达到购买上限": "購入上限に達しました",
|
||||
"已达上限": "上限に達しました",
|
||||
"立即订阅": "今すぐサブスクリプション",
|
||||
"暂无可购买套餐": "購入可能なプランがありません",
|
||||
"该套餐未配置 Stripe": "このプランには Stripe が設定されていません",
|
||||
"已打开支付页面": "決済ページを開きました",
|
||||
"支付失败": "支払いに失敗しました",
|
||||
"该套餐未配置 Creem": "このプランには Creem が設定されていません",
|
||||
"已发起支付": "支払いを開始しました",
|
||||
"购买订阅套餐": "サブスクリプションプランを購入",
|
||||
"套餐名称": "プラン名",
|
||||
"应付金额": "支払金額",
|
||||
"支付": "支払う",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "管理者がオンライン決済を有効にしていません。管理者に連絡してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2476,7 +2476,6 @@
|
||||
"重新生成备用码失败": "Не удалось сгенерировать резервные коды заново",
|
||||
"重新生成备用码将使现有的备用码失效,请确保您已保存了当前的备用码。": "Повторная генерация резервных кодов сделает существующие резервные коды недействительными, убедитесь, что вы сохранили текущие резервные коды.",
|
||||
"重绘": "Перерисовать",
|
||||
"重置": "Сброс",
|
||||
"重置 2FA": "Сброс 2FA",
|
||||
"重置 Passkey": "Сброс Passkey",
|
||||
"重置为默认": "Сбросить по умолчанию",
|
||||
@@ -2617,6 +2616,92 @@
|
||||
"格式化 JSON": "Форматировать JSON",
|
||||
"关闭提示": "Закрыть уведомление",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Примечание: тесты на этой странице используют нестриминговые запросы. Если канал поддерживает только стриминговые ответы, тест может завершиться неудачей. Ориентируйтесь на реальное использование.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление endpoint'ов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами»."
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Примечание: сопоставление эндпоинтов используется только для отображения в «Маркетплейсе моделей» и не влияет на реальный вызов. Чтобы настроить реальное поведение вызовов, перейдите в «Управление каналами».",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Товары Stripe/Creem нужно создать на сторонней платформе и указать их ID",
|
||||
"暂无订阅套餐": "Нет тарифных планов",
|
||||
"订阅管理": "Управление подписками",
|
||||
"订阅套餐管理": "Управление тарифами подписки",
|
||||
"新建套餐": "Создать план",
|
||||
"套餐": "План",
|
||||
"支付渠道": "Платежные каналы",
|
||||
"购买上限": "Лимит покупок",
|
||||
"有效期": "Срок действия",
|
||||
"重置": "Сброс",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "После отключения план не будет отображаться пользователям, но история заказов не затрагивается. Продолжить?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "После включения план будет отображаться пользователям. Продолжить?",
|
||||
"更新套餐信息": "Обновить информацию о плане",
|
||||
"创建新的订阅套餐": "Создать новый план подписки",
|
||||
"套餐的基本信息和定价": "Основная информация и цена плана",
|
||||
"套餐标题": "Название плана",
|
||||
"请输入套餐标题": "Введите название плана",
|
||||
"套餐副标题": "Подзаголовок плана",
|
||||
"例如:适合轻度使用": "Например: для легкого использования",
|
||||
"请输入金额": "Введите сумму",
|
||||
"请输入总额度": "Введите общий лимит",
|
||||
"0 表示不限": "0 означает без лимита",
|
||||
"原生额度": "Исходный лимит",
|
||||
"升级分组": "Группа повышения",
|
||||
"不升级": "Не повышать",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "Покупка или ручное добавление подписки повысит группу до этой. При истечении/аннулировании/удалении плана произойдет возврат к предыдущей группе. Возврат обычно занимает несколько минут.",
|
||||
"币种": "Валюта",
|
||||
"由全站货币展示设置统一控制": "Управляется глобальными настройками отображения валюты",
|
||||
"排序": "Порядок",
|
||||
"启用状态": "Статус включения",
|
||||
"有效期设置": "Настройки срока действия",
|
||||
"配置套餐的有效时长": "Настроить срок действия плана",
|
||||
"有效期单位": "Единица срока",
|
||||
"自定义秒数": "Пользовательские секунды",
|
||||
"请输入秒数": "Введите количество секунд",
|
||||
"有效期数值": "Значение срока",
|
||||
"额度重置": "Сброс лимита",
|
||||
"支持周期性重置套餐权益额度": "Поддерживает периодический сброс лимита плана",
|
||||
"重置周期": "Период сброса",
|
||||
"第三方支付配置": "Настройки сторонних платежей",
|
||||
"Stripe/Creem 商品ID(可选)": "ID продукта Stripe/Creem (необязательно)",
|
||||
"生效": "Активно",
|
||||
"已作废": "Аннулировано",
|
||||
"用户订阅管理": "Управление подписками пользователей",
|
||||
"选择订阅套餐": "Выберите план подписки",
|
||||
"新增订阅": "Добавить подписку",
|
||||
"暂无订阅记录": "Нет записей подписок",
|
||||
"来源": "Источник",
|
||||
"开始": "Начало",
|
||||
"结束": "Окончание",
|
||||
"作废": "Аннулировать",
|
||||
"确认作废": "Подтвердить аннулирование",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "После аннулирования подписка сразу станет недействительной. История не изменится. Продолжить?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Удаление полностью удалит запись подписки (включая детали прав). Продолжить?",
|
||||
"绑定订阅套餐": "Привязать план подписки",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "После привязки подписка будет создана сразу (без оплаты); срок действия рассчитывается по настройкам плана.",
|
||||
"订阅套餐": "Планы подписки",
|
||||
"购买订阅获得模型额度/次数": "Купите подписку, чтобы получить лимит/количество использования моделей",
|
||||
"优先订阅": "Сначала подписка",
|
||||
"优先钱包": "Сначала кошелек",
|
||||
"仅用订阅": "Только подписка",
|
||||
"仅用钱包": "Только кошелек",
|
||||
"我的订阅": "Мои подписки",
|
||||
"个生效中": "активных",
|
||||
"无生效": "Нет активных",
|
||||
"个已过期": "истекших",
|
||||
"订阅": "Подписка",
|
||||
"至": "до",
|
||||
"过期于": "Истекает",
|
||||
"购买套餐后即可享受模型权益": "После покупки плана доступны преимущества моделей",
|
||||
"限购": "Лимит",
|
||||
"推荐": "Рекомендуется",
|
||||
"已达到购买上限": "Достигнут лимит покупок",
|
||||
"已达上限": "Лимит достигнут",
|
||||
"立即订阅": "Оформить сейчас",
|
||||
"暂无可购买套餐": "Нет доступных для покупки планов",
|
||||
"该套餐未配置 Stripe": "Для этого плана не настроен Stripe",
|
||||
"已打开支付页面": "Страница оплаты открыта",
|
||||
"支付失败": "Оплата не удалась",
|
||||
"该套餐未配置 Creem": "Для этого плана не настроен Creem",
|
||||
"已发起支付": "Оплата инициирована",
|
||||
"购买订阅套餐": "Купить план подписки",
|
||||
"套餐名称": "Название плана",
|
||||
"应付金额": "К оплате",
|
||||
"支付": "Оплатить",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "Онлайн-оплата не включена администратором. Пожалуйста, свяжитесь с администратором."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3167,6 +3167,89 @@
|
||||
"格式化 JSON": "Định dạng JSON",
|
||||
"关闭提示": "Đóng thông báo",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "Lưu ý: Bài kiểm tra trên trang này sử dụng yêu cầu không streaming. Nếu kênh chỉ hỗ trợ phản hồi streaming, bài kiểm tra có thể thất bại. Vui lòng dựa vào sử dụng thực tế.",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Lưu ý: Ánh xạ endpoint chỉ dùng để hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi thực tế. Để cấu hình gọi thực tế, vui lòng vào \"Quản lý kênh\"."
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "Lưu ý: Ánh xạ endpoint chỉ dùng để hiển thị trong \"Chợ mô hình\" và không ảnh hưởng đến việc gọi thực tế. Để cấu hình gọi thực tế, vui lòng vào \"Quản lý kênh\".",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Sản phẩm Stripe/Creem phải được tạo trên nền tảng bên thứ ba và điền ID",
|
||||
"暂无订阅套餐": "Chưa có gói đăng ký",
|
||||
"订阅管理": "Quản lý đăng ký",
|
||||
"订阅套餐管理": "Quản lý gói đăng ký",
|
||||
"新建套餐": "Tạo gói",
|
||||
"套餐": "Gói",
|
||||
"支付渠道": "Kênh thanh toán",
|
||||
"购买上限": "Giới hạn mua",
|
||||
"有效期": "Thời hạn",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "Sau khi tắt, gói sẽ không hiển thị cho người dùng nhưng lịch sử đơn hàng không bị ảnh hưởng. Tiếp tục?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "Sau khi bật, gói sẽ hiển thị cho người dùng. Tiếp tục?",
|
||||
"更新套餐信息": "Cập nhật thông tin gói",
|
||||
"创建新的订阅套餐": "Tạo gói đăng ký mới",
|
||||
"套餐的基本信息和定价": "Thông tin cơ bản và giá của gói",
|
||||
"套餐标题": "Tiêu đề gói",
|
||||
"请输入套餐标题": "Vui lòng nhập tiêu đề gói",
|
||||
"套餐副标题": "Phụ đề gói",
|
||||
"例如:适合轻度使用": "Ví dụ: Phù hợp dùng nhẹ",
|
||||
"请输入总额度": "Vui lòng nhập tổng hạn mức",
|
||||
"0 表示不限": "0 nghĩa là không giới hạn",
|
||||
"原生额度": "Hạn mức gốc",
|
||||
"升级分组": "Nhóm nâng cấp",
|
||||
"不升级": "Không nâng cấp",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "Mua hoặc thêm thủ công đăng ký sẽ nâng cấp lên nhóm này. Khi gói hết hạn/vô hiệu/xóa, sẽ quay lại nhóm trước. Việc quay lại không áp dụng ngay và thường mất vài phút.",
|
||||
"币种": "Tiền tệ",
|
||||
"由全站货币展示设置统一控制": "Được điều khiển bởi cài đặt hiển thị tiền tệ toàn site",
|
||||
"排序": "Thứ tự",
|
||||
"启用状态": "Trạng thái bật",
|
||||
"有效期设置": "Cài đặt thời hạn",
|
||||
"配置套餐的有效时长": "Cấu hình thời lượng hiệu lực của gói",
|
||||
"有效期单位": "Đơn vị thời hạn",
|
||||
"自定义秒数": "Số giây tùy chỉnh",
|
||||
"请输入秒数": "Vui lòng nhập số giây",
|
||||
"有效期数值": "Giá trị thời hạn",
|
||||
"额度重置": "Đặt lại hạn mức",
|
||||
"支持周期性重置套餐权益额度": "Hỗ trợ đặt lại định kỳ hạn mức quyền lợi của gói",
|
||||
"重置周期": "Chu kỳ đặt lại",
|
||||
"第三方支付配置": "Cấu hình thanh toán bên thứ ba",
|
||||
"Stripe/Creem 商品ID(可选)": "ID sản phẩm Stripe/Creem (tùy chọn)",
|
||||
"生效": "Có hiệu lực",
|
||||
"已作废": "Đã vô hiệu",
|
||||
"用户订阅管理": "Quản lý đăng ký người dùng",
|
||||
"选择订阅套餐": "Chọn gói đăng ký",
|
||||
"新增订阅": "Thêm đăng ký",
|
||||
"暂无订阅记录": "Chưa có bản ghi đăng ký",
|
||||
"来源": "Nguồn",
|
||||
"开始": "Bắt đầu",
|
||||
"结束": "Kết thúc",
|
||||
"作废": "Vô hiệu",
|
||||
"确认作废": "Xác nhận vô hiệu",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "Sau khi vô hiệu, đăng ký sẽ mất hiệu lực ngay. Lịch sử không bị ảnh hưởng. Tiếp tục?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "Xóa sẽ loại bỏ hoàn toàn bản ghi đăng ký (bao gồm chi tiết quyền lợi). Tiếp tục?",
|
||||
"绑定订阅套餐": "Liên kết gói đăng ký",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "Sau khi liên kết, sẽ tạo đăng ký cho người dùng ngay (không cần thanh toán); thời hạn theo cấu hình gói.",
|
||||
"订阅套餐": "Gói đăng ký",
|
||||
"购买订阅获得模型额度/次数": "Mua đăng ký để nhận hạn mức/lượt dùng mô hình",
|
||||
"优先订阅": "Ưu tiên đăng ký",
|
||||
"优先钱包": "Ưu tiên ví",
|
||||
"仅用订阅": "Chỉ dùng đăng ký",
|
||||
"仅用钱包": "Chỉ dùng ví",
|
||||
"我的订阅": "Đăng ký của tôi",
|
||||
"个生效中": "gói đăng ký đang hiệu lực",
|
||||
"无生效": "Không có gói đăng ký hiệu lực",
|
||||
"个已过期": "gói đăng ký đã hết hạn",
|
||||
"订阅": "Đăng ký",
|
||||
"过期于": "Hết hạn vào",
|
||||
"购买套餐后即可享受模型权益": "Mua gói để nhận quyền lợi mô hình",
|
||||
"限购": "Giới hạn mua",
|
||||
"推荐": "Đề xuất",
|
||||
"已达到购买上限": "Đã đạt giới hạn mua",
|
||||
"已达上限": "Đã đạt giới hạn",
|
||||
"立即订阅": "Đăng ký ngay",
|
||||
"暂无可购买套餐": "Không có gói có thể mua",
|
||||
"该套餐未配置 Stripe": "Gói này chưa cấu hình Stripe",
|
||||
"已打开支付页面": "Đã mở trang thanh toán",
|
||||
"支付失败": "Thanh toán thất bại",
|
||||
"该套餐未配置 Creem": "Gói này chưa cấu hình Creem",
|
||||
"已发起支付": "Đã khởi tạo thanh toán",
|
||||
"购买订阅套餐": "Mua gói đăng ký",
|
||||
"套餐名称": "Tên gói",
|
||||
"应付金额": "Số tiền phải trả",
|
||||
"支付": "Thanh toán",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "Quản trị viên chưa bật thanh toán trực tuyến, vui lòng liên hệ quản trị viên."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}",
|
||||
@@ -2556,7 +2609,6 @@
|
||||
"默认补全倍率": "默认补全倍率",
|
||||
"每日签到": "每日签到",
|
||||
"今日已签到,累计签到": "今日已签到,累计签到",
|
||||
"天": "天",
|
||||
"每日签到可获得随机额度奖励": "每日签到可获得随机额度奖励",
|
||||
"今日已签到": "今日已签到",
|
||||
"立即签到": "立即签到",
|
||||
@@ -2588,6 +2640,91 @@
|
||||
"关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?": "关闭后将不再显示此提示(仅对当前浏览器生效)。确定要关闭吗?",
|
||||
"关闭提示": "关闭提示",
|
||||
"说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。": "说明:本页测试为非流式请求;若渠道仅支持流式返回,可能出现测试失败,请以实际使用为准。",
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。"
|
||||
"提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。": "提示:端点映射仅用于模型广场展示,不会影响模型真实调用。如需配置真实调用,请前往「渠道管理」。",
|
||||
"Stripe/Creem 需在第三方平台创建商品并填入 ID": "Stripe/Creem 需在第三方平台创建商品并填入 ID",
|
||||
"暂无订阅套餐": "暂无订阅套餐",
|
||||
"订阅管理": "订阅管理",
|
||||
"订阅套餐管理": "订阅套餐管理",
|
||||
"新建套餐": "新建套餐",
|
||||
"套餐": "套餐",
|
||||
"支付渠道": "支付渠道",
|
||||
"购买上限": "购买上限",
|
||||
"有效期": "有效期",
|
||||
"禁用后用户端不再展示,但历史订单不受影响。是否继续?": "禁用后用户端不再展示,但历史订单不受影响。是否继续?",
|
||||
"启用后套餐将在用户端展示。是否继续?": "启用后套餐将在用户端展示。是否继续?",
|
||||
"更新套餐信息": "更新套餐信息",
|
||||
"创建新的订阅套餐": "创建新的订阅套餐",
|
||||
"套餐的基本信息和定价": "套餐的基本信息和定价",
|
||||
"套餐标题": "套餐标题",
|
||||
"请输入套餐标题": "请输入套餐标题",
|
||||
"套餐副标题": "套餐副标题",
|
||||
"例如:适合轻度使用": "例如:适合轻度使用",
|
||||
"请输入金额": "请输入金额",
|
||||
"请输入总额度": "请输入总额度",
|
||||
"0 表示不限": "0 表示不限",
|
||||
"原生额度": "原生额度",
|
||||
"升级分组": "升级分组",
|
||||
"不升级": "不升级",
|
||||
"购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。": "购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。",
|
||||
"币种": "币种",
|
||||
"由全站货币展示设置统一控制": "由全站货币展示设置统一控制",
|
||||
"排序": "排序",
|
||||
"启用状态": "启用状态",
|
||||
"有效期设置": "有效期设置",
|
||||
"配置套餐的有效时长": "配置套餐的有效时长",
|
||||
"有效期单位": "有效期单位",
|
||||
"自定义秒数": "自定义秒数",
|
||||
"请输入秒数": "请输入秒数",
|
||||
"有效期数值": "有效期数值",
|
||||
"额度重置": "额度重置",
|
||||
"支持周期性重置套餐权益额度": "支持周期性重置套餐权益额度",
|
||||
"重置周期": "重置周期",
|
||||
"第三方支付配置": "第三方支付配置",
|
||||
"Stripe/Creem 商品ID(可选)": "Stripe/Creem 商品ID(可选)",
|
||||
"生效": "生效",
|
||||
"已作废": "已作废",
|
||||
"用户订阅管理": "用户订阅管理",
|
||||
"选择订阅套餐": "选择订阅套餐",
|
||||
"新增订阅": "新增订阅",
|
||||
"暂无订阅记录": "暂无订阅记录",
|
||||
"来源": "来源",
|
||||
"开始": "开始",
|
||||
"结束": "结束",
|
||||
"作废": "作废",
|
||||
"确认作废": "确认作废",
|
||||
"作废后该订阅将立即失效,历史记录不受影响。是否继续?": "作废后该订阅将立即失效,历史记录不受影响。是否继续?",
|
||||
"删除会彻底移除该订阅记录(含权益明细)。是否继续?": "删除会彻底移除该订阅记录(含权益明细)。是否继续?",
|
||||
"绑定订阅套餐": "绑定订阅套餐",
|
||||
"绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。": "绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。",
|
||||
"订阅套餐": "订阅套餐",
|
||||
"购买订阅获得模型额度/次数": "购买订阅获得模型额度/次数",
|
||||
"优先订阅": "优先订阅",
|
||||
"优先钱包": "优先钱包",
|
||||
"仅用订阅": "仅用订阅",
|
||||
"仅用钱包": "仅用钱包",
|
||||
"我的订阅": "我的订阅",
|
||||
"个生效中": "个生效中",
|
||||
"无生效": "无生效",
|
||||
"个已过期": "个已过期",
|
||||
"订阅": "订阅",
|
||||
"至": "至",
|
||||
"过期于": "过期于",
|
||||
"购买套餐后即可享受模型权益": "购买套餐后即可享受模型权益",
|
||||
"限购": "限购",
|
||||
"推荐": "推荐",
|
||||
"已达到购买上限": "已达到购买上限",
|
||||
"已达上限": "已达上限",
|
||||
"立即订阅": "立即订阅",
|
||||
"暂无可购买套餐": "暂无可购买套餐",
|
||||
"该套餐未配置 Stripe": "该套餐未配置 Stripe",
|
||||
"已打开支付页面": "已打开支付页面",
|
||||
"支付失败": "支付失败",
|
||||
"该套餐未配置 Creem": "该套餐未配置 Creem",
|
||||
"已发起支付": "已发起支付",
|
||||
"购买订阅套餐": "购买订阅套餐",
|
||||
"套餐名称": "套餐名称",
|
||||
"应付金额": "应付金额",
|
||||
"支付": "支付",
|
||||
"管理员未开启在线支付功能,请联系管理员配置。": "管理员未开启在线支付功能,请联系管理员配置。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ export default function SettingsSidebarModulesAdmin(props) {
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
});
|
||||
@@ -125,6 +126,7 @@ export default function SettingsSidebarModulesAdmin(props) {
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
@@ -193,6 +195,7 @@ export default function SettingsSidebarModulesAdmin(props) {
|
||||
deployment: true,
|
||||
redemption: true,
|
||||
user: true,
|
||||
subscription: true,
|
||||
setting: true,
|
||||
},
|
||||
};
|
||||
@@ -257,6 +260,11 @@ export default function SettingsSidebarModulesAdmin(props) {
|
||||
title: t('模型部署'),
|
||||
description: t('模型部署管理'),
|
||||
},
|
||||
{
|
||||
key: 'subscription',
|
||||
title: t('订阅管理'),
|
||||
description: t('订阅套餐管理'),
|
||||
},
|
||||
{
|
||||
key: 'redemption',
|
||||
title: t('兑换码管理'),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user