feat: 支持 MiniMax 音色克隆和 2.8 语音模型

This commit is contained in:
2026-06-17 02:13:21 +08:00
parent 02ba5d3cdd
commit c4341335d7
20 changed files with 1645 additions and 10 deletions
+4 -4
View File
@@ -105,7 +105,7 @@ WHERE p.status = 'enabled'
AND (m.cooldown_until IS NULL OR m.cooldown_until <= now())
AND (
(
$2::text IN ('audio_generate', 'text_to_speech')
$2::text IN ('audio_generate', 'text_to_speech', 'voice_clone')
AND (
m.model_alias = $1::text
OR m.model_name = $1::text
@@ -123,7 +123,7 @@ WHERE p.status = 'enabled'
)
)
OR (
$2::text NOT IN ('audio_generate', 'text_to_speech')
$2::text NOT IN ('audio_generate', 'text_to_speech', 'voice_clone')
AND (
(
COALESCE(m.model_alias, '') <> ''
@@ -419,7 +419,7 @@ WHERE p.status = 'enabled'
AND m.model_type @> jsonb_build_array($2::text)
AND (
(
$2::text IN ('audio_generate', 'text_to_speech')
$2::text IN ('audio_generate', 'text_to_speech', 'voice_clone')
AND (
m.model_alias = $1::text
OR m.model_name = $1::text
@@ -437,7 +437,7 @@ WHERE p.status = 'enabled'
)
)
OR (
$2::text NOT IN ('audio_generate', 'text_to_speech')
$2::text NOT IN ('audio_generate', 'text_to_speech', 'voice_clone')
AND (
(
COALESCE(m.model_alias, '') <> ''
+264
View File
@@ -0,0 +1,264 @@
package store
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/easyai/easyai-ai-gateway/apps/api/internal/auth"
)
type ClonedVoice struct {
ID string `json:"id"`
GatewayUserID string `json:"gatewayUserId,omitempty"`
UserID string `json:"userId"`
GatewayTenantID string `json:"gatewayTenantId,omitempty"`
TenantID string `json:"tenantId,omitempty"`
TenantKey string `json:"tenantKey,omitempty"`
Provider string `json:"provider"`
PlatformID string `json:"platformId,omitempty"`
PlatformName string `json:"platformName,omitempty"`
PlatformModelID string `json:"platformModelId,omitempty"`
Model string `json:"model,omitempty"`
PreviewModel string `json:"previewModel,omitempty"`
VoiceID string `json:"voiceId"`
DisplayName string `json:"displayName,omitempty"`
DemoAudioURL string `json:"demoAudioUrl,omitempty"`
Status string `json:"status"`
ExpiresAt string `json:"expiresAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ClonedVoiceInput struct {
GatewayUserID string
UserID string
GatewayTenantID string
TenantID string
TenantKey string
Provider string
PlatformID string
PlatformModelID string
SourceTaskID string
SourceAttemptID string
Model string
PreviewModel string
VoiceID string
DisplayName string
DemoAudioURL string
Status string
ExpiresAt *time.Time
Metadata map[string]any
}
const clonedVoiceColumns = `
v.id::text, COALESCE(v.gateway_user_id::text, ''), v.user_id,
COALESCE(v.gateway_tenant_id::text, ''), COALESCE(v.tenant_id, ''), COALESCE(v.tenant_key, ''),
v.provider, COALESCE(v.platform_id::text, ''), COALESCE(p.name, ''),
COALESCE(v.platform_model_id::text, ''), COALESCE(v.model, ''), COALESCE(v.preview_model, ''),
v.voice_id, COALESCE(v.display_name, ''), COALESCE(v.demo_audio_url, ''), v.status,
COALESCE(v.expires_at::text, ''), COALESCE(v.last_used_at::text, ''),
COALESCE(v.metadata, '{}'::jsonb), v.created_at, v.updated_at`
func (s *Store) UpsertClonedVoice(ctx context.Context, input ClonedVoiceInput) (ClonedVoice, error) {
metadata, _ := json.Marshal(emptyObjectIfNil(input.Metadata))
status := strings.TrimSpace(input.Status)
if status == "" {
status = "active"
}
return scanClonedVoice(s.pool.QueryRow(ctx, `
WITH upsert AS (
INSERT INTO gateway_cloned_voices (
gateway_user_id, user_id, gateway_tenant_id, tenant_id, tenant_key,
provider, platform_id, platform_model_id, source_task_id, source_attempt_id,
model, preview_model, voice_id, display_name, demo_audio_url, status, expires_at, metadata
)
VALUES (
NULLIF($1, '')::uuid, $2, NULLIF($3, '')::uuid, NULLIF($4, ''), NULLIF($5, ''),
$6, NULLIF($7, '')::uuid, NULLIF($8, '')::uuid, NULLIF($9, '')::uuid, NULLIF($10, '')::uuid,
$11, $12, $13, $14, $15, $16, $17, $18::jsonb
)
ON CONFLICT (platform_id, voice_id) WHERE platform_id IS NOT NULL AND voice_id <> ''
DO UPDATE SET
gateway_user_id = EXCLUDED.gateway_user_id,
user_id = EXCLUDED.user_id,
gateway_tenant_id = EXCLUDED.gateway_tenant_id,
tenant_id = EXCLUDED.tenant_id,
tenant_key = EXCLUDED.tenant_key,
provider = EXCLUDED.provider,
platform_model_id = EXCLUDED.platform_model_id,
source_task_id = EXCLUDED.source_task_id,
source_attempt_id = EXCLUDED.source_attempt_id,
model = EXCLUDED.model,
preview_model = EXCLUDED.preview_model,
display_name = EXCLUDED.display_name,
demo_audio_url = EXCLUDED.demo_audio_url,
status = EXCLUDED.status,
expires_at = EXCLUDED.expires_at,
metadata = gateway_cloned_voices.metadata || EXCLUDED.metadata,
updated_at = now()
RETURNING *
)
SELECT `+clonedVoiceColumns+`
FROM upsert v
LEFT JOIN integration_platforms p ON p.id = v.platform_id`,
input.GatewayUserID,
input.UserID,
input.GatewayTenantID,
input.TenantID,
input.TenantKey,
input.Provider,
input.PlatformID,
input.PlatformModelID,
input.SourceTaskID,
input.SourceAttemptID,
input.Model,
input.PreviewModel,
input.VoiceID,
input.DisplayName,
input.DemoAudioURL,
status,
input.ExpiresAt,
string(metadata),
))
}
func (s *Store) ListClonedVoices(ctx context.Context, user *auth.User) ([]ClonedVoice, error) {
gatewayUserID, userID := clonedVoiceUserKeys(user)
rows, err := s.pool.Query(ctx, `
SELECT `+clonedVoiceColumns+`
FROM gateway_cloned_voices v
LEFT JOIN integration_platforms p ON p.id = v.platform_id
WHERE (
NULLIF($1, '')::uuid IS NOT NULL
AND v.gateway_user_id = NULLIF($1, '')::uuid
)
OR (
NULLIF($2, '') IS NOT NULL
AND v.user_id = $2
)
ORDER BY v.created_at DESC`, gatewayUserID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]ClonedVoice, 0)
for rows.Next() {
item, err := scanClonedVoice(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) FindClonedVoiceForUser(ctx context.Context, user *auth.User, clonedVoiceID string, voiceID string) (ClonedVoice, bool, error) {
gatewayUserID, userID := clonedVoiceUserKeys(user)
clonedVoiceID = strings.TrimSpace(clonedVoiceID)
voiceID = strings.TrimSpace(voiceID)
if clonedVoiceID == "" && voiceID == "" {
return ClonedVoice{}, false, nil
}
item, err := scanClonedVoice(s.pool.QueryRow(ctx, `
SELECT `+clonedVoiceColumns+`
FROM gateway_cloned_voices v
LEFT JOIN integration_platforms p ON p.id = v.platform_id
WHERE (
(
NULLIF($1, '')::uuid IS NOT NULL
AND v.gateway_user_id = NULLIF($1, '')::uuid
)
OR (
NULLIF($2, '') IS NOT NULL
AND v.user_id = $2
)
)
AND (
(NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid)
OR (NULLIF($4, '') IS NOT NULL AND v.voice_id = $4)
)
ORDER BY CASE WHEN NULLIF($3, '')::uuid IS NOT NULL AND v.id = NULLIF($3, '')::uuid THEN 0 ELSE 1 END,
v.created_at DESC
LIMIT 1`, gatewayUserID, userID, clonedVoiceID, voiceID))
if err != nil {
if IsNotFound(err) {
return ClonedVoice{}, false, nil
}
return ClonedVoice{}, false, err
}
return item, true, nil
}
func (s *Store) TouchClonedVoiceUsage(ctx context.Context, clonedVoiceID string) error {
if strings.TrimSpace(clonedVoiceID) == "" {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE gateway_cloned_voices
SET last_used_at = now(), expires_at = now() + interval '7 days', updated_at = now()
WHERE id = $1::uuid`, clonedVoiceID)
return err
}
func (s *Store) MarkClonedVoiceStatus(ctx context.Context, clonedVoiceID string, status string) error {
if strings.TrimSpace(clonedVoiceID) == "" || strings.TrimSpace(status) == "" {
return nil
}
_, err := s.pool.Exec(ctx, `
UPDATE gateway_cloned_voices
SET status = $2, updated_at = now()
WHERE id = $1::uuid`, clonedVoiceID, status)
return err
}
func clonedVoiceUserKeys(user *auth.User) (string, string) {
if user == nil {
return "", ""
}
gatewayUserID := strings.TrimSpace(user.GatewayUserID)
if gatewayUserID == "" && user.Source == "gateway" {
gatewayUserID = strings.TrimSpace(user.ID)
}
userID := strings.TrimSpace(user.ID)
return gatewayUserID, userID
}
type clonedVoiceScanner interface {
Scan(dest ...any) error
}
func scanClonedVoice(scanner clonedVoiceScanner) (ClonedVoice, error) {
var item ClonedVoice
var metadata []byte
if err := scanner.Scan(
&item.ID,
&item.GatewayUserID,
&item.UserID,
&item.GatewayTenantID,
&item.TenantID,
&item.TenantKey,
&item.Provider,
&item.PlatformID,
&item.PlatformName,
&item.PlatformModelID,
&item.Model,
&item.PreviewModel,
&item.VoiceID,
&item.DisplayName,
&item.DemoAudioURL,
&item.Status,
&item.ExpiresAt,
&item.LastUsedAt,
&metadata,
&item.CreatedAt,
&item.UpdatedAt,
); err != nil {
return ClonedVoice{}, err
}
item.Metadata = decodeObject(metadata)
return item, nil
}
@@ -57,7 +57,7 @@ func billingResourcesForModelTypes(modelTypes []string) map[string]bool {
case "video", "videos.generations", "video_generate", "image_to_video", "text_to_video",
"video_edit", "omni_video", "video_reference", "video_first_last_frame":
resources["video"] = true
case "audio", "text_to_speech", "speech":
case "audio", "text_to_speech", "speech", "voice_clone":
resources["audio"] = true
case "music", "music_generate", "audio_generate":
resources["music"] = true
+1 -1
View File
@@ -23,7 +23,7 @@ type Store struct {
}
func defaultAPIKeyScopes() []string {
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio"}
return []string{"chat", "embedding", "rerank", "image", "video", "music", "audio", "voice_clone"}
}
func normalizeAPIKeyScopes(scopes []string) []string {